Compare commits

..

145 Commits

Author SHA1 Message Date
semantic-release-bot
990b573baa chore(abacus-react): release v2.21.0 [skip ci]
# [2.21.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.20.0...abacus-react-v2.21.0) (2026-01-03)

### Bug Fixes

* **practice:** add fallback error message when photo upload is blocked ([33efdf0](33efdf0c0d))
* **vision:** hide detection overlay when auto-detection disabled ([995cb60](995cb60086))
* **vision:** remote camera connection and session management ([8a45415](8a454158b5))

### Features

* add LLM client package and worksheet parsing infrastructure ([5a4c751](5a4c751ebe))
* **observer:** responsive session observer layout ([9610ddb](9610ddb8f1))
* **worksheet-parsing:** add parsing UI and fix parent access control ([91aaddb](91aaddbeab))
* **worksheet-parsing:** add selective re-parsing and improve UI ([830a48e](830a48e74f))
2026-01-03 02:42:27 +00:00
Thomas Hallock
830a48e74f feat(worksheet-parsing): add selective re-parsing and improve UI
Selective Re-parsing:
- Add parse-selected API endpoint for re-parsing specific problems
- Support user-adjusted bounding boxes that persist across re-parses
- Add crop-utils for extracting problem regions from worksheet images

LLM Metadata Tracking:
- Store JSON schema, prompt, and raw response in database
- Add debug panel in PhotoViewerEditor to inspect LLM details
- Add migrations for llm_metadata, llm_prompt, llm_json_schema columns

UI Improvements:
- Remove selection mode toggle - problems always selectable
- Show checkboxes on hover only (no layout jump)
- Move selection toolbar to fixed footer outside scrollable area
- Add BoundingBoxOverlay component for visual problem selection
- Add EditableProblemRow with hover-based checkbox visibility
- Unified hover highlighting across checkbox and problem cells

Also includes:
- Fix approve route to handle excluded problems correctly
- Add DebugContentModal for viewing prompts/responses
- Update LLM client to return metadata in responses

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 20:41:09 -06:00
Thomas Hallock
33efdf0c0d fix(practice): add fallback error message when photo upload is blocked
When canUpload is false but there's no specific remediation available
(e.g., due to a bug in access control), show a generic "Unable to upload
photos" banner instead of silently hiding the upload buttons.

This ensures users see feedback when access is unexpectedly denied,
rather than being confused by missing UI elements.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 14:14:20 -06:00
Thomas Hallock
91aaddbeab feat(worksheet-parsing): add parsing UI and fix parent access control
Worksheet Parsing UI (Slices 1-2):
- Add parse button to OfflineWorkSection thumbnails and PhotoViewerEditor
- Create ParsedProblemsList component to display extracted problems
- Add useWorksheetParsing hook with mutations for parse/review/approve
- Add attachmentKeys to queryKeys for cache management
- Wire up parsing workflow in SummaryClient

Fix parent upload access:
- Change /api/players/[id]/access to use getDbUserId() instead of getViewerId()
- Guest users' guestId was not matching parent_child.parent_user_id
- Parents can now see upload/camera buttons in offline work section

Fix curriculum type errors:
- Add missing 'advanced' property to createFullSkillSet()
- Fix enabledRequiredSkills -> enabledAllowedSkills in problem-generator
- Remove incorrect Partial<> wrapper from type casts

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 14:10:29 -06:00
Thomas Hallock
5a4c751ebe feat: add LLM client package and worksheet parsing infrastructure
Part A: @soroban/llm-client package
- Multi-provider support (OpenAI, Anthropic) via env vars
- Zod schema validation for structured LLM responses
- Retry loop with validation error feedback in prompt
- Progress indication hooks for UI feedback
- Vision support for image analysis

Part B: Worksheet parsing feature
- Zod schemas for parsed worksheet problems
- LLM prompt builder for abacus workbook images
- Parser using llm.vision() with retry logic
- Session converter to create SlotResults for BKT
- Database migration for parsing columns
- API routes: /parse, /review, /approve workflow

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 08:49:48 -06:00
Thomas Hallock
9610ddb8f1 feat(observer): responsive session observer layout
- Make session observer modal/page fully responsive for all screen sizes
- Replace absolute positioning with flex layout for problem + abacus
- Create MobileResultsSummary component for compact results on small screens
- Full-screen modal on mobile, centered dialog on desktop
- Stack problem and abacus vertically on small screens (<640px)
- Reduce vertical spacing to eliminate scrolling on mobile
- Hide desktop results panel on mobile, show compact summary chip

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:32:20 -06:00
Thomas Hallock
d80601d162 chore: add vision planning doc, storybook story, and update gitignore
- Add VISION_DOCK_INTEGRATION_PLAN.md for vision dock architecture
- Add VisionCameraControls.stories.tsx for storybook
- Update .gitignore to exclude venv, uploads, and training data

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:43:06 -06:00
Thomas Hallock
995cb60086 fix(vision): hide detection overlay when auto-detection disabled
Add ENABLE_AUTO_DETECTION flag to ObserverVisionFeed.tsx to hide the
useless detection overlay that always showed "---" and "0%" since
auto-detection is globally disabled. This matches the pattern already
used in DockedVisionFeed.tsx.

Also includes minor formatting fixes from Biome.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:37:37 -06:00
Thomas Hallock
8a454158b5 fix(vision): remote camera connection and session management
- Fix race condition in useRemoteCameraDesktop where session ID wasn't
  saved before socket connection check, preventing auto-reconnect
- Same fix in useRemoteCameraPhone for phone-side connection
- Fix "new session" button in RemoteCameraQRCode - properly clears old
  session and creates new one using prevRef to detect state changes
- Show full QR code UI with copyable URL (removed compact mode)
- Redesign AbacusVisionBridge UI: camera feed as hero, toolbar on feed,
  collapsible crop settings, source selector as tabs

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 19:01:46 -06:00
semantic-release-bot
41aa7ff33f chore(abacus-react): release v2.20.0 [skip ci]
# [2.20.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.19.0...abacus-react-v2.20.0) (2026-01-02)

### Bug Fixes

* **vision:** clear config when switching camera sources ([ff59612](ff59612e7b))
* **vision:** hide flip camera button when only one camera available ([7a9185e](7a9185eadb))
* **vision:** include remote camera in isVisionSetupComplete check ([a8fb77e](a8fb77e8e3))
* **vision:** remote camera persistence and UI bugs ([d90d263](d90d263b2a))

### Features

* **vision:** add activeCameraSource tracking and simplify calibration UI ([1be6151](1be6151bae))
* **vision:** add CV-based bead detection and fix remote camera connection ([005140a](005140a1e7))
* **vision:** add TensorFlow.js column classifier model and improve detection ([5d0ac65](5d0ac65bdd))
* **vision:** broadcast vision frames to observers (Phase 5) ([b3b769c](b3b769c0e2))
* **vision:** disable auto-detection with feature flag ([a5025f0](a5025f01bc))
* **vision:** integrate vision feed into docked abacus ([d8c7645](d8c764595d))
2026-01-02 00:02:33 +00:00
Thomas Hallock
1be6151bae feat(vision): add activeCameraSource tracking and simplify calibration UI
- Add explicit activeCameraSource field to VisionConfig to track which
  camera is in use (local vs phone), fixing button visibility bugs when
  switching between camera sources
- Simplify calibration UI by removing the confusing "Auto/Manual" mode
  toggle, replacing with a cleaner crop status indicator
- Remove calibration requirement from isVisionSetupComplete for local
  camera since auto-crop runs continuously when markers are detected
- Update DockedVisionFeed to use activeCameraSource instead of inferring
  from which configs are set

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 18:01:12 -06:00
Thomas Hallock
70b363ce88 refactor(vision): combine setup modal into single draggable experience
- Merge VisionSetupModal and AbacusVisionBridge into unified UI
- Remove two-step configuration process (no more "Configure Camera" button)
- Add vision control props to AbacusVisionBridge:
  - showVisionControls, isVisionEnabled, isVisionSetupComplete
  - onToggleVision, onClearSettings callbacks
- Add Enable/Disable Vision and Clear Settings buttons to bridge footer
- Simplify VisionSetupModal from ~257 to ~93 lines
- Modal is now draggable via framer-motion (built into AbacusVisionBridge)

User experience: Open modal → immediately see camera feed and all controls
in one place. Drag modal anywhere. Configure, enable/disable, close.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 17:30:46 -06:00
Thomas Hallock
d90d263b2a fix(vision): remote camera persistence and UI bugs
- Fix camera source switching: clear remoteCameraSessionId in context when
  switching to local camera so DockedVisionFeed uses the correct source
- Fix modal drag during calibration: disable framer-motion drag when
  calibration overlay is active to allow handle dragging
- Fix initial camera source: pass initialCameraSource prop to
  AbacusVisionBridge so it shows phone camera when reconfiguring remote
- Extend session TTL from 10 to 60 minutes for remote camera sessions
- Add localStorage persistence for remote camera session IDs
- Add auto-reconnect logic for both desktop and phone hooks
- Add comprehensive tests for session-manager, useRemoteCameraDesktop,
  and useRemoteCameraPhone hooks
- Guard test setup.ts for node environment (HTMLImageElement check)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 17:23:27 -06:00
Thomas Hallock
43524d8238 test: add unit tests for vision broadcast feature
- VisionIndicator.test.tsx: tests for rendering, status indicator, click behavior, accessibility
- ObserverVisionFeed.test.tsx: tests for image display, detected value, live/stale indicator
- useSessionBroadcast.vision.test.ts: tests for sendVisionFrame socket emission
- useSessionObserver.vision.test.ts: tests for visionFrame receiving and cleanup
- MyAbacusContext.vision.test.tsx: tests for vision config state and callbacks

Also fixes:
- useSessionObserver: clear visionFrame and transitionState on stopObserving
- test/setup.ts: add canvas Image mock to prevent jsdom errors with data URIs

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 16:08:51 -06:00
Thomas Hallock
a5025f01bc feat(vision): disable auto-detection with feature flag
- Add ENABLE_AUTO_DETECTION flag (set to false) in DockedVisionFeed
- Conditionally import detection modules for tree-shaking when disabled
- Guard all detection processing, loops, and value handlers
- Hide detection overlay when auto-detection is disabled
- Remove vision toggle button from ActiveSession (no longer needed)
- Clean up unused imports and code
- Format fixes from biome

The camera feed still works for observation mode, but the ML/CV
bead detection is disabled until accuracy is improved.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:55:50 -06:00
Thomas Hallock
a8fb77e8e3 fix(vision): include remote camera in isVisionSetupComplete check
The isVisionSetupComplete flag was only checking for local camera
setup (cameraDeviceId + calibration), which caused remote camera
mode to be treated as "not configured" even when connected.

Now considers vision setup complete if either:
- Local camera: has camera device AND calibration
- Remote camera: has remote session ID (phone handles calibration)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:36:27 -06:00
Thomas Hallock
e80ef04f45 chore(vision): clean up debug console.log statements
Remove unnecessary debug logging from vision components
that was used during development.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:31:06 -06:00
Thomas Hallock
b3b769c0e2 feat(vision): broadcast vision frames to observers (Phase 5)
Wire up the vision broadcast pipeline:

1. DockedVisionFeed captures rectified frames from canvas and emits
   them at 5fps via the context's emitVisionFrame callback

2. PracticeClient wires setVisionFrameCallback to call sendVisionFrame
   from useSessionBroadcast, connecting the context to the socket

3. useSessionBroadcast sends VisionFrameEvent to the session channel
   with imageData, detectedValue, and confidence

4. socket-server relays vision-frame events to observers

5. useSessionObserver receives and stores visionFrame for display

6. SessionObserverModal shows ObserverVisionFeed when visionFrame
   is available, replacing the interactive AbacusDock with the
   student's live camera feed

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:28:59 -06:00
Thomas Hallock
ff59612e7b fix(vision): clear config when switching camera sources
When switching between local and phone camera, clear the other
source's configuration to prevent stale data.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:13:20 -06:00
Thomas Hallock
d8c764595d feat(vision): integrate vision feed into docked abacus
- Add vision state management to MyAbacusContext (camera, calibration,
  remote session, enabled state)
- Add VisionIndicator component showing vision status on dock header
- Add VisionSetupModal for configuring camera and calibration
- Add DockedVisionFeed component that replaces SVG abacus when vision
  is enabled, with:
  - Continuous ArUco marker detection for auto-calibration
  - OpenCV perspective correction via VisionCameraFeed
  - Real-time bead detection and value display
  - Support for both local camera and remote phone camera
- Wire AbacusVisionBridge to save config to context via
  onConfigurationChange callback
- Update MyAbacus to conditionally render DockedVisionFeed vs
  AbacusReact based on vision state

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:05:58 -06:00
Thomas Hallock
005140a1e7 feat(vision): add CV-based bead detection and fix remote camera connection
- Add beadDetector.ts with intensity-profile-based bead detection (CV approach)
- Integrate CV pipeline for both local camera and remote phone camera feeds
- Add processImageFrame() to frameProcessor for remote camera image processing
- Fix React 18 Strict Mode duplicate session creation in RemoteCameraQRCode
- Add debug logging to remote camera hooks for connection troubleshooting
- Add VisionStatusIndicator for remote camera feed in AbacusVisionBridge

The duplicate session bug was caused by React 18 Strict Mode double-mounting
components and running effects twice with fresh state, which called
createSession() twice and created two different sessions - phone joined
one, desktop subscribed to the other.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:29:02 -06:00
Thomas Hallock
5d0ac65bdd feat(vision): add TensorFlow.js column classifier model and improve detection
- Add trained CNN model for abacus column digit classification
  - model.json: TensorFlow.js layers model (fixed for Keras 3 compatibility)
  - group1-shard1of1.bin: quantized model weights (~2.2MB)

- Improve detection performance and stability
  - Throttle inference to 5fps (was running every animation frame)
  - Lower stability threshold: 3 consecutive frames (was 10)
  - Lower confidence threshold: 50% (was 70%)

- Clean up debug logging from development

Note: Model trained on synthetic data, accuracy on real images is limited.
Future work: retrain on real abacus photos for better accuracy.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 22:59:40 -06:00
Thomas Hallock
7a9185eadb fix(vision): hide flip camera button when only one camera available
Only show camera controls section when there's something to display:
- Flip button: only if multiple cameras
- Torch button: only if torch available
- Whole section: only if either button would be shown

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 22:35:52 -06:00
semantic-release-bot
da97ad0675 chore(abacus-react): release v2.19.0 [skip ci]
# [2.19.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.18.0...abacus-react-v2.19.0) (2026-01-01)

### Features

* **vision:** add physical abacus column setting and fix remote flash toggle ([b206eb3](b206eb3071))
* **vision:** improve remote camera calibration and UX ([8846cec](8846cece93))
2026-01-01 04:35:16 +00:00
Thomas Hallock
b206eb3071 feat(vision): add physical abacus column setting and fix remote flash toggle
Physical Abacus Columns Setting:
- Add physicalAbacusColumns to AbacusDisplayConfig (default: 4)
- Add database column with migration 0054
- Add slider UI in AbacusDisplayDropdown (range 1-21)
- Update AbacusVisionBridge to use setting instead of calculating from problem

Remote Camera Flash Toggle Fix:
- Add socket events for torch sync (set-torch, torch-state)
- Phone reports torch state to desktop on change/connection
- Desktop can control phone's torch remotely
- Add torch button in AbacusVisionBridge for phone camera mode
- Both local and remote flash toggles now work correctly

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 22:33:58 -06:00
Thomas Hallock
8846cece93 feat(vision): improve remote camera calibration and UX
- Fix remote camera autocrop rotation by swapping ArUco corners for phone camera
  (detectMarkers assumes Desk View orientation which is 180° rotated)
- Add rotate left/right buttons to CalibrationOverlay for manual calibration
- Fix mode switching bug: switching to auto mode now clears desktop calibration
  on phone via new 'remote-camera:clear-calibration' socket event
- Add copy button to QR code URL with visual feedback
- Fix text selection spanning into video feed with userSelect: none
- Add flip camera and torch controls to local camera UI
- Add session persistence for remote camera reconnection
- Fix 4:3 aspect ratio for cropped abacus output

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 22:04:06 -06:00
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
475 changed files with 132614 additions and 27451 deletions

View File

@@ -212,7 +212,289 @@
"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(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 )",
"Bash(open -a Preview:*)",
"Bash(pip3 install:*)",
"Bash(pip3 uninstall:*)",
"Bash(/opt/homebrew/bin/python3:*)",
"Bash(/usr/bin/python3:*)",
"Bash(/opt/homebrew/bin/pip3 install:*)",
"Bash(source:*)",
"Bash(pip install:*)",
"Bash(/opt/homebrew/opt/python@3.11/bin/python3.11:*)",
"Bash(tensorflowjs_converter:*)",
"Bash(public/models/abacus-column-classifier/column-classifier.keras )",
"Bash(public/models/abacus-column-classifier/)",
"Bash(public/models/abacus-column-classifier/column-classifier.h5 )",
"Bash(apps/web/scripts/train-column-classifier/train_model.py )",
"Bash(apps/web/src/app/remote-camera/[sessionId]/page.tsx )",
"Bash(apps/web/src/hooks/useColumnClassifier.ts )",
"Bash(apps/web/src/lib/vision/columnClassifier.ts )",
"Bash(\"apps/web/src/app/remote-camera/[sessionId]/page.tsx\" )",
"Bash(apps/web/drizzle/0054_new_mathemanic.sql )",
"Bash(apps/web/drizzle/meta/0054_snapshot.json )",
"Bash(apps/web/src/components/AbacusDisplayDropdown.tsx )",
"Bash(apps/web/src/db/schema/abacus-settings.ts )",
"Bash(packages/abacus-react/src/AbacusContext.tsx)",
"Bash(apps/web/src/lib/vision/frameProcessor.ts )",
"Bash(apps/web/src/lib/vision/beadDetector.ts )",
"Bash(apps/web/public/models/abacus-column-classifier/model.json )",
"Bash(.claude/settings.local.json)",
"Bash(apps/web/src/components/MyAbacus.tsx )",
"Bash(apps/web/src/contexts/MyAbacusContext.tsx )",
"Bash(apps/web/src/components/vision/DockedVisionFeed.tsx )",
"Bash(apps/web/src/components/vision/VisionIndicator.tsx )",
"Bash(apps/web/src/components/vision/VisionSetupModal.tsx)",
"Bash(npx storybook:*)",
"Bash(apps/web/src/hooks/usePhoneCamera.ts )",
"Bash(apps/web/src/lib/remote-camera/session-manager.ts )",
"Bash(apps/web/src/test/setup.ts )",
"Bash(apps/web/src/hooks/__tests__/useRemoteCameraDesktop.test.ts )",
"Bash(apps/web/src/hooks/__tests__/useRemoteCameraPhone.test.ts )",
"Bash(apps/web/src/lib/remote-camera/__tests__/)",
"Bash(packages/abacus-react/CHANGELOG.md )",
"WebFetch(domain:zod.dev)",
"Bash(npm view:*)",
"Bash(tsc:*)",
"WebFetch(domain:colinhacks.com)",
"Bash(npm install:*)",
"Bash(corepack prepare:*)",
"Bash(/Users/antialias/Library/pnpm/pnpm self-update:*)",
"Bash(readlink:*)",
"Bash(src/app/api/curriculum/[playerId]/attachments/[attachmentId]/approve/route.ts )",
"Bash(src/app/api/curriculum/[playerId]/attachments/[attachmentId]/parse/route.ts )",
"Bash(src/app/api/curriculum/[playerId]/attachments/[attachmentId]/review/route.ts )",
"Bash(src/app/api/curriculum/[playerId]/sessions/[sessionId]/attachments/route.ts )",
"Bash(src/app/api/players/[id]/access/route.ts )",
"Bash(src/app/practice/[studentId]/summary/SummaryClient.tsx )",
"Bash(src/components/worksheet-parsing/ )",
"Bash(src/hooks/useLLMCall.ts )",
"Bash(src/hooks/usePlayerAccess.ts )",
"Bash(src/hooks/useWorksheetParsing.ts )",
"Bash(src/lib/classroom/access-control.ts )",
"Bash(src/lib/classroom/index.ts )",
"Bash(src/lib/curriculum/definitions.ts )",
"Bash(src/lib/curriculum/problem-generator.ts )",
"Bash(src/lib/worksheet-parsing/parser.ts )",
"Bash(src/lib/worksheet-parsing/schemas.ts )",
"Bash(src/lib/worksheet-parsing/session-converter.ts )",
"Bash(src/types/css.d.ts )",
"Bash(tsconfig.json)",
"Bash(git status:*)",
"Bash(photos\" banner instead of silently hiding the upload buttons.\n\nThis ensures users see feedback when access is unexpectedly denied,\nrather than being confused by missing UI elements.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"WebFetch(domain:platform.openai.com)",
"WebFetch(domain:cookbook.openai.com)",
"WebFetch(domain:docs.aimlapi.com)"
],
"deny": [],
"ask": []

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

@@ -68,7 +68,7 @@ export interface SlotResult {
timestamp: number;
responseTimeMs: number;
userAnswer: number | null;
helpLevel: 0 | 1 | 2 | 3;
hadHelp: boolean; // Whether student used help during this problem
}
```
@@ -220,20 +220,16 @@ export function updateOnIncorrect(
// src/lib/curriculum/bkt/evidence-quality.ts
/**
* Adjust observation weight based on help level.
* More help = less confident the student really knows it.
* 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 helpLevelWeight(helpLevel: 0 | 1 | 2 | 3): number {
switch (helpLevel) {
case 0:
return 1.0; // No help - full evidence
case 1:
return 0.8; // Minor hint - slight reduction
case 2:
return 0.5; // Significant help - halve evidence
case 3:
return 0.5; // Full help - halve evidence
}
export function helpWeight(hadHelp: boolean): number {
return hadHelp ? 0.5 : 1.0; // 50% weight for helped answers
}
/**
@@ -349,7 +345,7 @@ export function getUncertaintyRange(
import type { ProblemResultWithContext } from "../session-planner";
import { getDefaultParams, type BktParams } from "./skill-priors";
import { updateOnCorrect, updateOnIncorrect } from "./conjunctive-bkt";
import { helpLevelWeight, responseTimeWeight } from "./evidence-quality";
import { helpWeight, responseTimeWeight } from "./evidence-quality";
import { calculateConfidence, getUncertaintyRange } from "./confidence";
export interface BktComputeOptions {
@@ -432,12 +428,12 @@ export function computeBktFromHistory(
});
// Calculate evidence weight
const helpWeight = helpLevelWeight(result.helpLevel);
const helpW = helpWeight(result.hadHelp);
const rtWeight = responseTimeWeight(
result.responseTimeMs,
result.isCorrect,
);
const evidenceWeight = helpWeight * rtWeight;
const evidenceWeight = helpW * rtWeight;
// Compute updates
const updates = result.isCorrect
@@ -677,6 +673,123 @@ 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.

View File

@@ -12,119 +12,136 @@ To make the transition truly seamless, the text content stays the same from star
- **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.
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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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
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)})`
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)})`
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 {
function lerpGradient(
startStops: GradientStop[],
endStops: GradientStop[],
t: number,
): string {
const interpolatedStops = startStops.map((start, i) => {
const end = endStops[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(', ')})`
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 {
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)
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(', ')
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(", ");
}
```
@@ -134,24 +151,25 @@ 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
const BURST_DURATION = 5_000; // 5s full celebration
const WIND_DOWN_DURATION = 55_000; // 55s transition
if (elapsedMs < BURST_DURATION) return 0
if (elapsedMs < BURST_DURATION) return 0;
const windDownElapsed = elapsedMs - BURST_DURATION
if (windDownElapsed >= WIND_DOWN_DURATION) return 1
const windDownElapsed = elapsedMs - BURST_DURATION;
if (windDownElapsed >= WIND_DOWN_DURATION) return 1;
const t = windDownElapsed / WIND_DOWN_DURATION
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)
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)
@@ -188,9 +206,9 @@ Actually, for smooth wiggle wind-down, we should use a spring-based approach or
```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
const time = Date.now() / 500; // oscillation period
const amplitude = 3 * (1 - progress);
const rotation = Math.sin(time) * amplitude;
// transform: `rotate(${rotation}deg)`
```
@@ -199,72 +217,72 @@ const rotation = Math.sin(time) * amplitude
```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'
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
trophyOpacity: number;
graduationCapOpacity: number;
emojiSize: number;
emojiRotation: number;
emojiMarginBottom: number;
// Title
titleFontSize: number
titleColor: string
titleTextShadow: string
titleMarginBottom: number
celebrationTitleOpacity: number
normalTitleOpacity: number
titleFontSize: number;
titleColor: string;
titleTextShadow: string;
titleMarginBottom: number;
celebrationTitleOpacity: number;
normalTitleOpacity: number;
// Subtitle
subtitleFontSize: number
subtitleColor: string
subtitleMarginBottom: number
celebrationSubtitleOpacity: number
normalSubtitleOpacity: number
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
buttonPaddingX: number;
buttonPaddingY: number;
buttonFontSize: number;
buttonBackground: string;
buttonBorderRadius: number;
buttonBoxShadow: string;
buttonColor: string;
// Shimmer
shimmerOpacity: number
shimmerOpacity: number;
// Glow
glowIntensity: number
glowIntensity: number;
}
function calculateStyles(progress: number, isDark: boolean): CelebrationStyles {
const t = progress // 0 = celebration, 1 = normal
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
t,
),
containerBorderWidth: lerp(3, 1, t),
containerBorderColor: lerpColor('#eab308', '#3b82f6', 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',
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,
@@ -275,7 +293,11 @@ function calculateStyles(progress: number, isDark: boolean): CelebrationStyles {
// Title
titleFontSize: lerp(28, 16, t),
titleColor: lerpColor(isDark ? '#fef08a' : '#a16207', isDark ? '#93c5fd' : '#1d4ed8', 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,
@@ -283,7 +305,11 @@ function calculateStyles(progress: number, isDark: boolean): CelebrationStyles {
// Subtitle
subtitleFontSize: lerp(20, 14, t),
subtitleColor: lerpColor(isDark ? '#e5e7eb' : '#374151', isDark ? '#9ca3af' : '#4b5563', t),
subtitleColor: lerpColor(
isDark ? "#e5e7eb" : "#374151",
isDark ? "#9ca3af" : "#4b5563",
t,
),
subtitleMarginBottom: lerp(16, 0, t),
celebrationSubtitleOpacity: 1 - t,
normalSubtitleOpacity: t,
@@ -294,32 +320,42 @@ function calculateStyles(progress: number, isDark: boolean): CelebrationStyles {
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),
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)
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()
fireConfettiCelebration();
}
}, [shouldFireConfetti])
}, [shouldFireConfetti]);
// Calculate all interpolated styles
const styles = calculateStyles(progress, isDark)
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
@@ -330,54 +366,62 @@ function CelebrationProgressionBanner({ sessionMode, onAction, variant, isDark }
data-element="session-mode-banner"
data-celebration-progress={progress}
style={{
position: 'relative',
position: "relative",
background: styles.containerBackground,
borderWidth: `${styles.containerBorderWidth}px`,
borderStyle: 'solid',
borderStyle: "solid",
borderColor: styles.containerBorderColor,
borderRadius: `${styles.containerBorderRadius}px`,
padding: `${styles.containerPadding}px`,
boxShadow: styles.containerBoxShadow,
display: 'flex',
display: "flex",
flexDirection: styles.containerFlexDirection,
alignItems: styles.containerAlignItems,
textAlign: styles.containerTextAlign,
overflow: 'hidden',
overflow: "hidden",
}}
>
{/* Shimmer overlay - fades out */}
<div
style={{
position: 'absolute',
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',
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',
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,
}}>
<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
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
style={{
opacity: styles.graduationCapOpacity,
position:
styles.graduationCapOpacity < 0.5 ? "absolute" : "relative",
}}
>
🎓
</span>
</div>
@@ -385,43 +429,52 @@ function CelebrationProgressionBanner({ sessionMode, onAction, variant, isDark }
{/* 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`,
}}>
<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,
}}>
<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`,
}}>
<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>
You're ready to learn{" "}
<strong>{sessionMode.nextSkill.displayName}</strong>
</span>
<span style={{
opacity: styles.normalSubtitleOpacity,
position: 'absolute',
left: 0,
top: 0,
}}>
<span
style={{
opacity: styles.normalSubtitleOpacity,
position: "absolute",
left: 0,
top: 0,
}}
>
{sessionMode.nextSkill.displayName} Start the tutorial to begin
</span>
</div>
@@ -433,23 +486,27 @@ function CelebrationProgressionBanner({ sessionMode, onAction, variant, isDark }
style={{
padding: `${styles.buttonPaddingY}px ${styles.buttonPaddingX}px`,
fontSize: `${styles.buttonFontSize}px`,
fontWeight: 'bold',
fontWeight: "bold",
background: styles.buttonBackground,
color: styles.buttonColor,
borderRadius: `${styles.buttonBorderRadius}px`,
border: 'none',
border: "none",
boxShadow: styles.buttonBoxShadow,
cursor: 'pointer',
cursor: "pointer",
}}
>
{/* Button text also cross-fades */}
<span style={{ opacity: styles.celebrationTitleOpacity }}>Start Learning!</span>
<span style={{ opacity: styles.normalTitleOpacity, position: 'absolute' }}>
<span style={{ opacity: styles.celebrationTitleOpacity }}>
Start Learning!
</span>
<span
style={{ opacity: styles.normalTitleOpacity, position: "absolute" }}
>
Start Tutorial
</span>
</button>
</div>
)
);
}
```
@@ -459,40 +516,43 @@ 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)
const [progress, setProgress] = useState(0);
const [shouldFireConfetti, setShouldFireConfetti] = useState(false);
const [oscillation, setOscillation] = useState(0);
useEffect(() => {
const state = getCelebrationState(skillId)
const state = getCelebrationState(skillId);
if (!state) {
// First time seeing this skill unlock
setCelebrationState(skillId, { startedAt: Date.now(), confettiFired: false })
setShouldFireConfetti(true)
setCelebrationState(skillId, {
startedAt: Date.now(),
confettiFired: false,
});
setShouldFireConfetti(true);
}
let rafId: number
let rafId: number;
const animate = () => {
const state = getCelebrationState(skillId)
if (!state) return
const state = getCelebrationState(skillId);
if (!state) return;
const elapsed = Date.now() - state.startedAt
const newProgress = windDownProgress(elapsed)
const elapsed = Date.now() - state.startedAt;
const newProgress = windDownProgress(elapsed);
setProgress(newProgress)
setOscillation(Math.sin(Date.now() / 500)) // For wiggle
setProgress(newProgress);
setOscillation(Math.sin(Date.now() / 500)); // For wiggle
if (newProgress < 1) {
rafId = requestAnimationFrame(animate)
rafId = requestAnimationFrame(animate);
}
}
};
rafId = requestAnimationFrame(animate)
return () => cancelAnimationFrame(rafId)
}, [skillId])
rafId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafId);
}, [skillId]);
return { progress, shouldFireConfetti, oscillation }
return { progress, shouldFireConfetti, oscillation };
}
```

View File

@@ -91,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.**
@@ -810,10 +965,30 @@ When adding/modifying database schema:
mcp__sqlite__describe_table table_name
```
**CRITICAL: Verify migration timestamp order after generation:**
After running `npx drizzle-kit generate --custom`, check `drizzle/meta/_journal.json`:
1. Look at the `"when"` timestamp of the new migration
2. Verify it's GREATER than the previous migration's timestamp
3. If not, manually edit the journal to use a timestamp after the previous one
Example of broken ordering (0057 before 0056):
```json
{ "idx": 56, "when": 1767484800000, "tag": "0056_..." }, // Jan 3
{ "idx": 57, "when": 1767400331475, "tag": "0057_..." } // Jan 2 - WRONG!
```
Fix by setting 0057's timestamp to be after 0056:
```json
{ "idx": 57, "when": 1767571200000, "tag": "0057_..." } // Jan 4 - CORRECT
```
**Why this happens:** `drizzle-kit generate` uses current system time, but if previous migrations were manually given future timestamps (common in CI/production scenarios), new migrations can get timestamps that sort incorrectly.
**What NOT to do:**
- ❌ DO NOT manually create SQL files in `drizzle/` without using `drizzle-kit generate`
- ❌ DO NOT manually edit `drizzle/meta/_journal.json`
- ❌ DO NOT manually edit `drizzle/meta/_journal.json` (except to fix timestamp ordering)
- ❌ DO NOT run SQL directly with `sqlite3` command
- ❌ DO NOT use `drizzle-kit generate` without `--custom` flag (it requires interactive prompts)
@@ -918,6 +1093,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:

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

@@ -42,7 +42,8 @@ When `sessionMode.type === 'remediation'`:
```typescript
// Derive whether to show remediation CTA
const showRemediationCta = sessionMode.type === 'remediation' && sessionMode.weakSkills.length > 0
const showRemediationCta =
sessionMode.type === "remediation" && sessionMode.weakSkills.length > 0;
```
### Step 2: Create RemediationCta component section
@@ -107,26 +108,28 @@ Add after the Tutorial CTA section (line ~1428), or restructure to have a single
### Step 3: Update start button visibility logic
Change from:
```tsx
{!showTutorialGate && (
<button>Let's Go! </button>
)}
{
!showTutorialGate && <button>Let's Go! </button>;
}
```
To:
```tsx
{!showTutorialGate && !showRemediationCta && (
<button>Let's Go! </button>
)}
{
!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! →" |
| 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

View File

@@ -3,6 +3,7 @@
## 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
@@ -10,6 +11,7 @@ The current architecture has three independent BKT computations:
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"
@@ -17,6 +19,7 @@ Additionally, students see conflicting signals:
## 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)
@@ -31,32 +34,32 @@ A single `SessionMode` object computed once and used everywhere:
```typescript
interface SkillInfo {
skillId: string
displayName: string
pKnown: number // 0-1 probability
skillId: string;
displayName: string;
pKnown: number; // 0-1 probability
}
type SessionMode =
| {
type: 'remediation'
weakSkills: SkillInfo[]
focusDescription: string
type: "remediation";
weakSkills: SkillInfo[];
focusDescription: string;
// What promotion is being blocked
blockedPromotion?: {
nextSkill: SkillInfo
reason: string // "Strengthen +3 and +5-2 first"
}
nextSkill: SkillInfo;
reason: string; // "Strengthen +3 and +5-2 first"
};
}
| {
type: 'progression'
nextSkill: SkillInfo
tutorialRequired: boolean
focusDescription: string
type: "progression";
nextSkill: SkillInfo;
tutorialRequired: boolean;
focusDescription: string;
}
| {
type: 'maintenance'
focusDescription: string // "All skills strong - mixed practice"
}
type: "maintenance";
focusDescription: string; // "All skills strong - mixed practice"
};
```
## UI States
@@ -64,6 +67,7 @@ type SessionMode =
### Dashboard Banner Area
**Progression Mode:**
```
┌────────────────────────────────────────────────────────────┐
│ 🌟 New Skill Unlocked! │
@@ -73,6 +77,7 @@ type SessionMode =
```
**Remediation Mode (with blocked promotion):**
```
┌────────────────────────────────────────────────────────────┐
│ 🔒 Almost there! │
@@ -83,6 +88,7 @@ type SessionMode =
```
**Maintenance Mode:**
```
┌────────────────────────────────────────────────────────────┐
│ ✨ All skills strong! │
@@ -94,6 +100,7 @@ type SessionMode =
### Modal CTA Area
**Progression Mode:**
```
┌────────────────────────────────────────────────────────────┐
│ 🌟 You've unlocked: +5 - 4 │
@@ -105,6 +112,7 @@ type SessionMode =
```
**Remediation Mode:**
```
┌────────────────────────────────────────────────────────────┐
│ 💪 Strengthening weak skills │
@@ -135,6 +143,7 @@ type SessionMode =
## 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
@@ -142,6 +151,7 @@ type SessionMode =
- `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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,335 @@
# Plan: Abacus Vision as Docked Abacus Video Source
**Status:** In Progress
**Created:** 2026-01-01
**Last Updated:** 2026-01-01
## Overview
Transform abacus vision from a standalone modal into an alternate "source" for the docked abacus. When vision is enabled, the processed camera feed replaces the SVG abacus representation in the dock.
**Current Architecture:**
```
AbacusDock → MyAbacus (SVG) → value displayed
AbacusVisionBridge → Modal → onValueDetected callback
```
**Target Architecture:**
```
AbacusDock
├─ [vision disabled] → MyAbacus (SVG)
└─ [vision enabled] → VisionFeed (processed video) + value detection
Broadcasts to observers
```
---
## Key Requirements
1. **Vision hint on docks** - Camera icon visible on/near AbacusDock
2. **Persistent across docking** - Vision icon stays visible when abacus is docked
3. **Setup gating** - Clicking opens setup if no source/calibration configured
4. **Video replaces SVG** - When enabled, camera feed shows instead of SVG abacus
5. **Observer visibility** - Teachers/parents see student's video feed during observation
---
## Progress Tracker
- [ ] **Phase 1:** Vision State in MyAbacusContext
- [ ] **Phase 2:** Vision Indicator on AbacusDock
- [ ] **Phase 3:** Video Feed Replaces Docked Abacus
- [ ] **Phase 4:** Vision Setup Modal Refactor
- [ ] **Phase 5:** Broadcast Video Feed to Observers
- [ ] **Phase 6:** Polish & Edge Cases
---
## Phase 1: Vision State in MyAbacusContext
**Goal:** Add vision-related state to the abacus context so it's globally accessible.
**Files to modify:**
- `apps/web/src/contexts/MyAbacusContext.tsx`
**State to add:**
```typescript
interface VisionConfig {
enabled: boolean
cameraDeviceId: string | null
calibration: CalibrationGrid | null
remoteCameraSessionId: string | null // For phone camera
}
// In context:
visionConfig: VisionConfig
setVisionEnabled: (enabled: boolean) => void
setVisionCalibration: (calibration: CalibrationGrid | null) => void
setVisionCamera: (deviceId: string | null) => void
isVisionSetupComplete: boolean // Derived: has camera AND calibration
```
**Persistence:** Save to localStorage alongside existing abacus display config.
**Testable outcome:**
- Open browser console, check that vision config is in context
- Toggle vision state programmatically, see it persist across refresh
---
## Phase 2: Vision Indicator on AbacusDock
**Goal:** Show a camera icon near the dock that indicates vision status and opens setup.
**Files to modify:**
- `apps/web/src/components/AbacusDock.tsx` - Add vision indicator
- `apps/web/src/components/MyAbacus.tsx` - Show indicator when docked
**UI Design:**
```
┌─────────────────────────────────┐
│ [Docked Abacus] [↗] │ ← Undock button (existing)
│ │
│ [📷] │ ← Vision toggle (NEW)
│ │
└─────────────────────────────────┘
```
**Behavior:**
- Icon shows camera with status indicator:
- 🔴 Red dot = not configured
- 🟢 Green dot = configured and enabled
- ⚪ No dot = configured but disabled
- Click opens VisionSetupModal (Phase 4)
- Visible in BOTH floating button AND docked states
**Testable outcome:**
- See camera icon on docked abacus
- Click icon, see setup modal open
- Icon shows different states based on config
---
## Phase 3: Video Feed Replaces Docked Abacus
**Goal:** When vision is enabled, render processed video instead of SVG abacus.
**Files to modify:**
- `apps/web/src/components/MyAbacus.tsx` - Conditional rendering
- Create: `apps/web/src/components/vision/DockedVisionFeed.tsx`
**DockedVisionFeed component:**
```typescript
interface DockedVisionFeedProps {
width: number;
height: number;
onValueDetected: (value: number) => void;
}
// Renders:
// - Processed/cropped camera feed
// - Overlays detected column values
// - Small "disable vision" button
```
**MyAbacus docked mode change:**
```tsx
// In docked rendering section:
{isDocked && (
visionConfig.enabled && isVisionSetupComplete ? (
<DockedVisionFeed
width={...}
height={...}
onValueDetected={setDockedValue}
/>
) : (
<Abacus value={abacusValue} ... />
)
)}
```
**Testable outcome:**
- Enable vision (manually set in console if needed)
- See video feed in dock instead of SVG abacus
- Detected values update the context
---
## Phase 4: Vision Setup Modal Refactor
**Goal:** Streamline the setup flow - AbacusVisionBridge becomes a setup wizard.
**Files to modify:**
- `apps/web/src/components/vision/AbacusVisionBridge.tsx` - Simplify to setup-only
- Create: `apps/web/src/components/vision/VisionSetupModal.tsx`
**Setup flow:**
```
[Open Modal]
Is camera selected? ─No──→ [Select Camera Screen]
│Yes ↓
↓ Select device
Is calibrated? ─No───→ [Calibration Screen]
│Yes ↓
↓ Manual or ArUco
[Ready Screen]
├─ Preview of what vision sees
├─ [Enable Vision] button
└─ [Reconfigure] button
```
**Quick-toggle behavior:**
- If fully configured: clicking vision icon toggles on/off immediately
- If not configured: opens setup modal
- Long-press or secondary click: always opens settings
**Testable outcome:**
- Complete setup flow from scratch
- Settings persist across refresh
- Quick toggle works when configured
---
## Phase 5: Broadcast Video Feed to Observers
**Goal:** Teachers/parents observing a session see the student's vision video feed.
**Files to modify:**
- `apps/web/src/hooks/useSessionBroadcast.ts` - Add vision frame broadcasting
- `apps/web/src/hooks/useSessionObserver.ts` - Receive vision frames
- `apps/web/src/components/classroom/SessionObserverView.tsx` - Display vision feed
**Broadcasting strategy:**
```typescript
// In useSessionBroadcast, when vision is enabled:
// Emit compressed frames at reduced rate (5 fps for bandwidth)
socket.emit("vision-frame", {
sessionId,
imageData: compressedJpegBase64,
timestamp: Date.now(),
detectedValue: currentValue,
});
// Also broadcast vision state:
socket.emit("practice-state", {
...existingState,
visionEnabled: true,
visionConfidence: confidence,
});
```
**Observer display:**
```typescript
// In SessionObserverView, when student has vision enabled:
// Show video feed instead of SVG abacus in the observation panel
{studentState.visionEnabled ? (
<ObserverVisionFeed frames={receivedFrames} />
) : (
<AbacusDock value={studentState.abacusValue} />
)}
```
**Testable outcome:**
- Student enables vision, starts practice
- Teacher opens observer modal
- Teacher sees student's camera feed (not SVG abacus)
---
## Phase 6: Polish & Edge Cases
**Goal:** Handle edge cases and improve UX.
**Items:**
1. **Connection loss handling** - Fall back to SVG if video stops
2. **Bandwidth management** - Adaptive quality based on connection
3. **Mobile optimization** - Vision setup works on phone screens
4. **Reconnection** - Re-establish vision feed after disconnect
5. **Multiple observers** - Efficient multicast of video frames
**Testable outcome:**
- Disconnect/reconnect scenarios work smoothly
- Mobile users can configure vision
- Multiple teachers can observe same student
---
## Implementation Order & Dependencies
```
Phase 1 (Foundation)
Phase 2 (UI Integration)
Phase 3 (Core Feature) ←── Requires Phase 1, 2
Phase 4 (UX Refinement) ←── Can start in parallel with Phase 3
Phase 5 (Observation) ←── Requires Phase 3
Phase 6 (Polish) ←── After all features work
```
---
## Files Summary
### Modify
| File | Changes |
| ---------------------------------------------- | --------------------------------------------- |
| `contexts/MyAbacusContext.tsx` | Add vision state, persistence |
| `components/MyAbacus.tsx` | Vision indicator, conditional video rendering |
| `components/AbacusDock.tsx` | Pass through vision-related props |
| `hooks/useSessionBroadcast.ts` | Emit vision frames |
| `hooks/useSessionObserver.ts` | Receive vision frames |
| `components/classroom/SessionObserverView.tsx` | Display vision feed |
### Create
| File | Purpose |
| ------------------------------------------ | ----------------------------- |
| `components/vision/VisionSetupModal.tsx` | Streamlined setup wizard |
| `components/vision/DockedVisionFeed.tsx` | Video display for docked mode |
| `components/vision/VisionIndicator.tsx` | Camera icon with status |
| `components/vision/ObserverVisionFeed.tsx` | Observer-side video display |
---
## Testing Checkpoints
After each phase, manually verify:
- [ ] **Phase 1:** Console shows vision config in context, persists on refresh
- [ ] **Phase 2:** Camera icon visible on dock, opens modal on click
- [ ] **Phase 3:** Enable vision → video shows in dock instead of SVG
- [ ] **Phase 4:** Full setup flow works, quick toggle works when configured
- [ ] **Phase 5:** Observer sees student's video feed during session
- [ ] **Phase 6:** Edge cases handled gracefully

View File

@@ -67,7 +67,10 @@
"WebSearch",
"Bash(npm run format:check:*)",
"Bash(ping:*)",
"Bash(dig:*)"
"Bash(dig:*)",
"Bash(pnpm why:*)",
"Bash(npm view:*)",
"Bash(pnpm install:*)"
],
"deny": [],
"ask": []

9
apps/web/.gitignore vendored
View File

@@ -54,3 +54,12 @@ src/generated/build-info.json
# biome
.biome
# Python virtual environments
.venv*/
# User uploads
data/uploads/
# ML training data
training-data/

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

View File

@@ -0,0 +1,2 @@
-- Add physical_abacus_columns column to abacus_settings table
ALTER TABLE `abacus_settings` ADD `physical_abacus_columns` integer DEFAULT 4 NOT NULL;

View File

@@ -0,0 +1,37 @@
-- Add LLM-powered worksheet parsing columns to practice_attachments
-- These columns support the workflow: parse → review → approve → create session
-- Parsing workflow status
ALTER TABLE `practice_attachments` ADD COLUMN `parsing_status` text;
--> statement-breakpoint
-- When parsing completed (ISO timestamp)
ALTER TABLE `practice_attachments` ADD COLUMN `parsed_at` text;
--> statement-breakpoint
-- Error message if parsing failed
ALTER TABLE `practice_attachments` ADD COLUMN `parsing_error` text;
--> statement-breakpoint
-- Raw LLM parsing result (JSON) - before user corrections
ALTER TABLE `practice_attachments` ADD COLUMN `raw_parsing_result` text;
--> statement-breakpoint
-- Approved result (JSON) - after user corrections
ALTER TABLE `practice_attachments` ADD COLUMN `approved_result` text;
--> statement-breakpoint
-- Overall confidence score from LLM (0-1)
ALTER TABLE `practice_attachments` ADD COLUMN `confidence_score` real;
--> statement-breakpoint
-- True if any problems need manual review
ALTER TABLE `practice_attachments` ADD COLUMN `needs_review` integer;
--> statement-breakpoint
-- True if a session was created from this parsed worksheet
ALTER TABLE `practice_attachments` ADD COLUMN `session_created` integer;
--> statement-breakpoint
-- Reference to the session created from this parsing
ALTER TABLE `practice_attachments` ADD COLUMN `created_session_id` text REFERENCES session_plans(id) ON DELETE SET NULL;

View File

@@ -0,0 +1,31 @@
-- Add LLM call metadata columns to practice_attachments
-- These provide transparency/debugging info about the parsing request
-- Which LLM provider was used (e.g., "openai", "anthropic")
ALTER TABLE `practice_attachments` ADD COLUMN `llm_provider` text;
--> statement-breakpoint
-- Which model was used (e.g., "gpt-4o", "claude-sonnet-4")
ALTER TABLE `practice_attachments` ADD COLUMN `llm_model` text;
--> statement-breakpoint
-- The full prompt sent to the LLM (for debugging)
ALTER TABLE `practice_attachments` ADD COLUMN `llm_prompt_used` text;
--> statement-breakpoint
-- Which image was sent: "cropped" or "original"
ALTER TABLE `practice_attachments` ADD COLUMN `llm_image_source` text;
--> statement-breakpoint
-- How many LLM call attempts were needed (retries on validation failure)
ALTER TABLE `practice_attachments` ADD COLUMN `llm_attempts` integer;
--> statement-breakpoint
-- Token usage for cost tracking
ALTER TABLE `practice_attachments` ADD COLUMN `llm_prompt_tokens` integer;
--> statement-breakpoint
ALTER TABLE `practice_attachments` ADD COLUMN `llm_completion_tokens` integer;
--> statement-breakpoint
ALTER TABLE `practice_attachments` ADD COLUMN `llm_total_tokens` integer;

View File

@@ -0,0 +1,3 @@
-- Custom SQL migration file, put your code below! --
-- Add llm_raw_response column to practice_attachments for storing raw LLM JSON responses
ALTER TABLE `practice_attachments` ADD `llm_raw_response` text;

View File

@@ -0,0 +1,2 @@
-- Add llm_json_schema column to practice_attachments for storing the JSON Schema sent to the LLM
ALTER TABLE `practice_attachments` ADD `llm_json_schema` text;

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

@@ -267,6 +267,153 @@
"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
},
{
"idx": 54,
"version": "6",
"when": 1767240895813,
"tag": "0054_new_mathemanic",
"breakpoints": true
},
{
"idx": 55,
"version": "6",
"when": 1767398400000,
"tag": "0055_add_attachment_parsing",
"breakpoints": true
},
{
"idx": 56,
"version": "6",
"when": 1767484800000,
"tag": "0056_add_llm_metadata",
"breakpoints": true
},
{
"idx": 57,
"version": "6",
"when": 1767571200000,
"tag": "0057_flowery_korath",
"breakpoints": true
},
{
"idx": 58,
"version": "6",
"when": 1767657600000,
"tag": "0058_blushing_impossible_man",
"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

@@ -54,6 +54,7 @@
"@react-three/fiber": "^8.17.0",
"@soroban/abacus-react": "workspace:*",
"@soroban/core": "workspace:*",
"@soroban/llm-client": "workspace:*",
"@soroban/templates": "workspace:*",
"@strudel/soundfonts": "^1.2.6",
"@strudel/web": "^1.2.6",
@@ -61,6 +62,8 @@
"@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",
@@ -74,9 +77,13 @@
"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",
@@ -89,6 +96,7 @@
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-resizable-layout": "^0.7.3",
"react-resizable-panels": "^3.0.6",
"react-simple-keyboard": "^3.8.139",
@@ -135,6 +143,7 @@
"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

@@ -204,6 +204,11 @@ export default defineConfig({
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.05)' },
},
// Pulse opacity - fading effect for loading states
pulseOpacity: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.5' },
},
// Error shake - stronger horizontal oscillation (line 2009)
errorShake: {
'0%, 100%': { transform: 'translateX(0)' },
@@ -239,6 +244,11 @@ export default defineConfig({
'0%, 100%': { opacity: '0.7' },
'50%': { opacity: '0.4' },
},
// Spin - rotate 360 degrees for spinners
spin: {
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' },
},
// Fade in with scale - entrance animation
fadeInScale: {
'0%': { opacity: '0', transform: 'scale(0.9)' },

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

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

View File

@@ -0,0 +1,778 @@
{
"format": "layers-model",
"generatedBy": "keras v3.13.0",
"convertedBy": "TensorFlow.js Converter v4.22.0",
"modelTopology": {
"keras_version": "3.13.0",
"backend": "tensorflow",
"model_config": {
"class_name": "Sequential",
"config": {
"name": "sequential",
"trainable": true,
"dtype": "float32",
"layers": [
{
"class_name": "InputLayer",
"config": {
"dtype": "float32",
"sparse": false,
"ragged": false,
"name": "input_layer",
"optional": false,
"batchInputShape": [null, 128, 64, 1]
}
},
{
"class_name": "Conv2D",
"config": {
"name": "conv2d",
"trainable": true,
"dtype": "float32",
"filters": 32,
"kernel_size": [3, 3],
"strides": [1, 1],
"padding": "same",
"data_format": "channels_last",
"dilation_rate": [1, 1],
"groups": 1,
"activation": "relu",
"use_bias": true,
"kernel_initializer": {
"module": "keras.initializers",
"class_name": "GlorotUniform",
"config": {
"seed": null
},
"registered_name": null
},
"bias_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"kernel_regularizer": null,
"bias_regularizer": null,
"activity_regularizer": null,
"kernel_constraint": null,
"bias_constraint": null
}
},
{
"class_name": "BatchNormalization",
"config": {
"name": "batch_normalization",
"trainable": true,
"dtype": "float32",
"axis": -1,
"momentum": 0.99,
"epsilon": 0.001,
"center": true,
"scale": true,
"beta_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"gamma_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"moving_mean_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"moving_variance_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"beta_regularizer": null,
"gamma_regularizer": null,
"beta_constraint": null,
"gamma_constraint": null,
"synchronized": false
}
},
{
"class_name": "MaxPooling2D",
"config": {
"name": "max_pooling2d",
"trainable": true,
"dtype": "float32",
"pool_size": [2, 2],
"padding": "valid",
"strides": [2, 2],
"data_format": "channels_last"
}
},
{
"class_name": "Dropout",
"config": {
"name": "dropout",
"trainable": true,
"dtype": "float32",
"rate": 0.25,
"seed": null,
"noise_shape": null
}
},
{
"class_name": "Conv2D",
"config": {
"name": "conv2d_1",
"trainable": true,
"dtype": "float32",
"filters": 64,
"kernel_size": [3, 3],
"strides": [1, 1],
"padding": "same",
"data_format": "channels_last",
"dilation_rate": [1, 1],
"groups": 1,
"activation": "relu",
"use_bias": true,
"kernel_initializer": {
"module": "keras.initializers",
"class_name": "GlorotUniform",
"config": {
"seed": null
},
"registered_name": null
},
"bias_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"kernel_regularizer": null,
"bias_regularizer": null,
"activity_regularizer": null,
"kernel_constraint": null,
"bias_constraint": null
}
},
{
"class_name": "BatchNormalization",
"config": {
"name": "batch_normalization_1",
"trainable": true,
"dtype": "float32",
"axis": -1,
"momentum": 0.99,
"epsilon": 0.001,
"center": true,
"scale": true,
"beta_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"gamma_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"moving_mean_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"moving_variance_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"beta_regularizer": null,
"gamma_regularizer": null,
"beta_constraint": null,
"gamma_constraint": null,
"synchronized": false
}
},
{
"class_name": "MaxPooling2D",
"config": {
"name": "max_pooling2d_1",
"trainable": true,
"dtype": "float32",
"pool_size": [2, 2],
"padding": "valid",
"strides": [2, 2],
"data_format": "channels_last"
}
},
{
"class_name": "Dropout",
"config": {
"name": "dropout_1",
"trainable": true,
"dtype": "float32",
"rate": 0.25,
"seed": null,
"noise_shape": null
}
},
{
"class_name": "Conv2D",
"config": {
"name": "conv2d_2",
"trainable": true,
"dtype": "float32",
"filters": 128,
"kernel_size": [3, 3],
"strides": [1, 1],
"padding": "same",
"data_format": "channels_last",
"dilation_rate": [1, 1],
"groups": 1,
"activation": "relu",
"use_bias": true,
"kernel_initializer": {
"module": "keras.initializers",
"class_name": "GlorotUniform",
"config": {
"seed": null
},
"registered_name": null
},
"bias_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"kernel_regularizer": null,
"bias_regularizer": null,
"activity_regularizer": null,
"kernel_constraint": null,
"bias_constraint": null
}
},
{
"class_name": "BatchNormalization",
"config": {
"name": "batch_normalization_2",
"trainable": true,
"dtype": "float32",
"axis": -1,
"momentum": 0.99,
"epsilon": 0.001,
"center": true,
"scale": true,
"beta_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"gamma_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"moving_mean_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"moving_variance_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"beta_regularizer": null,
"gamma_regularizer": null,
"beta_constraint": null,
"gamma_constraint": null,
"synchronized": false
}
},
{
"class_name": "MaxPooling2D",
"config": {
"name": "max_pooling2d_2",
"trainable": true,
"dtype": "float32",
"pool_size": [2, 2],
"padding": "valid",
"strides": [2, 2],
"data_format": "channels_last"
}
},
{
"class_name": "Dropout",
"config": {
"name": "dropout_2",
"trainable": true,
"dtype": "float32",
"rate": 0.25,
"seed": null,
"noise_shape": null
}
},
{
"class_name": "Flatten",
"config": {
"name": "flatten",
"trainable": true,
"dtype": "float32",
"data_format": "channels_last"
}
},
{
"class_name": "Dense",
"config": {
"name": "dense",
"trainable": true,
"dtype": "float32",
"units": 128,
"activation": "relu",
"use_bias": true,
"kernel_initializer": {
"module": "keras.initializers",
"class_name": "GlorotUniform",
"config": {
"seed": null
},
"registered_name": null
},
"bias_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"kernel_regularizer": null,
"bias_regularizer": null,
"kernel_constraint": null,
"bias_constraint": null,
"quantization_config": null
}
},
{
"class_name": "BatchNormalization",
"config": {
"name": "batch_normalization_3",
"trainable": true,
"dtype": "float32",
"axis": -1,
"momentum": 0.99,
"epsilon": 0.001,
"center": true,
"scale": true,
"beta_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"gamma_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"moving_mean_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"moving_variance_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"beta_regularizer": null,
"gamma_regularizer": null,
"beta_constraint": null,
"gamma_constraint": null,
"synchronized": false
}
},
{
"class_name": "Dropout",
"config": {
"name": "dropout_3",
"trainable": true,
"dtype": "float32",
"rate": 0.5,
"seed": null,
"noise_shape": null
}
},
{
"class_name": "Dense",
"config": {
"name": "dense_1",
"trainable": true,
"dtype": "float32",
"units": 10,
"activation": "softmax",
"use_bias": true,
"kernel_initializer": {
"module": "keras.initializers",
"class_name": "GlorotUniform",
"config": {
"seed": null
},
"registered_name": null
},
"bias_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"kernel_regularizer": null,
"bias_regularizer": null,
"kernel_constraint": null,
"bias_constraint": null,
"quantization_config": null
}
}
],
"build_input_shape": [null, 128, 64, 1]
}
},
"training_config": {
"loss": "sparse_categorical_crossentropy",
"loss_weights": null,
"metrics": ["accuracy"],
"weighted_metrics": null,
"run_eagerly": false,
"steps_per_execution": 1,
"jit_compile": false,
"optimizer_config": {
"class_name": "Adam",
"config": {
"name": "adam",
"learning_rate": 0.0010000000474974513,
"weight_decay": null,
"clipnorm": null,
"global_clipnorm": null,
"clipvalue": null,
"use_ema": false,
"ema_momentum": 0.99,
"ema_overwrite_frequency": null,
"loss_scale_factor": null,
"gradient_accumulation_steps": null,
"beta_1": 0.9,
"beta_2": 0.999,
"epsilon": 1e-7,
"amsgrad": false
}
}
}
},
"weightsManifest": [
{
"paths": ["group1-shard1of1.bin"],
"weights": [
{
"name": "batch_normalization/gamma",
"shape": [32],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.970035195350647,
"scale": 0.00039288062675326476,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization/beta",
"shape": [32],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.04866361422281639,
"scale": 0.00040217862994063134,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization/moving_mean",
"shape": [32],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.000010939256753772497,
"scale": 0.001048501559268391,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization/moving_variance",
"shape": [32],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.000532817910425365,
"scale": 0.00016297123568388176,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_1/gamma",
"shape": [64],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.9726127982139587,
"scale": 0.00019898110744999905,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_1/beta",
"shape": [64],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.06264814909766703,
"scale": 0.00037290564939087515,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_1/moving_mean",
"shape": [64],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.12544548511505127,
"scale": 0.001907470179539101,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_1/moving_variance",
"shape": [64],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.042508192360401154,
"scale": 0.002489794206385519,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_2/gamma",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.975760817527771,
"scale": 0.0003113854165170707,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_2/beta",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.023137448749998037,
"scale": 0.00013072004943501716,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_2/moving_mean",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.015866611152887344,
"scale": 0.005222073358063605,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_2/moving_variance",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.01432291604578495,
"scale": 0.00944612571860061,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_3/gamma",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.9765098690986633,
"scale": 0.0008689317048764697,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_3/beta",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.05253423078387391,
"scale": 0.00032833894239921196,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_3/moving_mean",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 2.3402893845059225e-8,
"scale": 0.124165194550534,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_3/moving_variance",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.000532600621227175,
"scale": 0.8092722632006888,
"original_dtype": "float32"
}
},
{
"name": "conv2d/kernel",
"shape": [3, 3, 1, 32],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.1684967933916578,
"scale": 0.0012961291799358293,
"original_dtype": "float32"
}
},
{
"name": "conv2d/bias",
"shape": [32],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.014791351323034248,
"scale": 0.00019462304372413485,
"original_dtype": "float32"
}
},
{
"name": "conv2d_1/kernel",
"shape": [3, 3, 32, 64],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.14185832411635155,
"scale": 0.0010912178778180888,
"original_dtype": "float32"
}
},
{
"name": "conv2d_1/bias",
"shape": [64],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.052345379924072934,
"scale": 0.00033341006321065564,
"original_dtype": "float32"
}
},
{
"name": "conv2d_2/kernel",
"shape": [3, 3, 64, 128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.09215074052997664,
"scale": 0.0007199276603904425,
"original_dtype": "float32"
}
},
{
"name": "conv2d_2/bias",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.052666782806901374,
"scale": 0.00035346834098591524,
"original_dtype": "float32"
}
},
{
"name": "dense/kernel",
"shape": [16384, 128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.1078803108311167,
"scale": 0.0006960020053620432,
"original_dtype": "float32"
}
},
{
"name": "dense/bias",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.010696043731535184,
"scale": 0.00013539295862702763,
"original_dtype": "float32"
}
},
{
"name": "dense_1/kernel",
"shape": [128, 10],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.26071277062098186,
"scale": 0.002190863618663713,
"original_dtype": "float32"
}
},
{
"name": "dense_1/bias",
"shape": [10],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.020677046455881174,
"scale": 0.00016028718182853623,
"original_dtype": "float32"
}
}
]
}
]
}

View File

@@ -0,0 +1,858 @@
{
"format": "layers-model",
"generatedBy": "keras v3.13.0",
"convertedBy": "TensorFlow.js Converter v4.22.0",
"modelTopology": {
"keras_version": "3.13.0",
"backend": "tensorflow",
"model_config": {
"class_name": "Sequential",
"config": {
"name": "sequential",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"layers": [
{
"class_name": "InputLayer",
"config": {
"batch_shape": [null, 128, 64, 1],
"dtype": "float32",
"sparse": false,
"ragged": false,
"name": "input_layer",
"optional": false
}
},
{
"class_name": "Conv2D",
"config": {
"name": "conv2d",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"filters": 32,
"kernel_size": [3, 3],
"strides": [1, 1],
"padding": "same",
"data_format": "channels_last",
"dilation_rate": [1, 1],
"groups": 1,
"activation": "relu",
"use_bias": true,
"kernel_initializer": {
"module": "keras.initializers",
"class_name": "GlorotUniform",
"config": { "seed": null },
"registered_name": null
},
"bias_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"kernel_regularizer": null,
"bias_regularizer": null,
"activity_regularizer": null,
"kernel_constraint": null,
"bias_constraint": null
}
},
{
"class_name": "BatchNormalization",
"config": {
"name": "batch_normalization",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"axis": -1,
"momentum": 0.99,
"epsilon": 0.001,
"center": true,
"scale": true,
"beta_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"gamma_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"moving_mean_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"moving_variance_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"beta_regularizer": null,
"gamma_regularizer": null,
"beta_constraint": null,
"gamma_constraint": null,
"synchronized": false
}
},
{
"class_name": "MaxPooling2D",
"config": {
"name": "max_pooling2d",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"pool_size": [2, 2],
"padding": "valid",
"strides": [2, 2],
"data_format": "channels_last"
}
},
{
"class_name": "Dropout",
"config": {
"name": "dropout",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"rate": 0.25,
"seed": null,
"noise_shape": null
}
},
{
"class_name": "Conv2D",
"config": {
"name": "conv2d_1",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"filters": 64,
"kernel_size": [3, 3],
"strides": [1, 1],
"padding": "same",
"data_format": "channels_last",
"dilation_rate": [1, 1],
"groups": 1,
"activation": "relu",
"use_bias": true,
"kernel_initializer": {
"module": "keras.initializers",
"class_name": "GlorotUniform",
"config": { "seed": null },
"registered_name": null
},
"bias_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"kernel_regularizer": null,
"bias_regularizer": null,
"activity_regularizer": null,
"kernel_constraint": null,
"bias_constraint": null
}
},
{
"class_name": "BatchNormalization",
"config": {
"name": "batch_normalization_1",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"axis": -1,
"momentum": 0.99,
"epsilon": 0.001,
"center": true,
"scale": true,
"beta_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"gamma_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"moving_mean_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"moving_variance_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"beta_regularizer": null,
"gamma_regularizer": null,
"beta_constraint": null,
"gamma_constraint": null,
"synchronized": false
}
},
{
"class_name": "MaxPooling2D",
"config": {
"name": "max_pooling2d_1",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"pool_size": [2, 2],
"padding": "valid",
"strides": [2, 2],
"data_format": "channels_last"
}
},
{
"class_name": "Dropout",
"config": {
"name": "dropout_1",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"rate": 0.25,
"seed": null,
"noise_shape": null
}
},
{
"class_name": "Conv2D",
"config": {
"name": "conv2d_2",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"filters": 128,
"kernel_size": [3, 3],
"strides": [1, 1],
"padding": "same",
"data_format": "channels_last",
"dilation_rate": [1, 1],
"groups": 1,
"activation": "relu",
"use_bias": true,
"kernel_initializer": {
"module": "keras.initializers",
"class_name": "GlorotUniform",
"config": { "seed": null },
"registered_name": null
},
"bias_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"kernel_regularizer": null,
"bias_regularizer": null,
"activity_regularizer": null,
"kernel_constraint": null,
"bias_constraint": null
}
},
{
"class_name": "BatchNormalization",
"config": {
"name": "batch_normalization_2",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"axis": -1,
"momentum": 0.99,
"epsilon": 0.001,
"center": true,
"scale": true,
"beta_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"gamma_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"moving_mean_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"moving_variance_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"beta_regularizer": null,
"gamma_regularizer": null,
"beta_constraint": null,
"gamma_constraint": null,
"synchronized": false
}
},
{
"class_name": "MaxPooling2D",
"config": {
"name": "max_pooling2d_2",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"pool_size": [2, 2],
"padding": "valid",
"strides": [2, 2],
"data_format": "channels_last"
}
},
{
"class_name": "Dropout",
"config": {
"name": "dropout_2",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"rate": 0.25,
"seed": null,
"noise_shape": null
}
},
{
"class_name": "Flatten",
"config": {
"name": "flatten",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"data_format": "channels_last"
}
},
{
"class_name": "Dense",
"config": {
"name": "dense",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"units": 128,
"activation": "relu",
"use_bias": true,
"kernel_initializer": {
"module": "keras.initializers",
"class_name": "GlorotUniform",
"config": { "seed": null },
"registered_name": null
},
"bias_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"kernel_regularizer": null,
"bias_regularizer": null,
"kernel_constraint": null,
"bias_constraint": null,
"quantization_config": null
}
},
{
"class_name": "BatchNormalization",
"config": {
"name": "batch_normalization_3",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"axis": -1,
"momentum": 0.99,
"epsilon": 0.001,
"center": true,
"scale": true,
"beta_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"gamma_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"moving_mean_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"moving_variance_initializer": {
"module": "keras.initializers",
"class_name": "Ones",
"config": {},
"registered_name": null
},
"beta_regularizer": null,
"gamma_regularizer": null,
"beta_constraint": null,
"gamma_constraint": null,
"synchronized": false
}
},
{
"class_name": "Dropout",
"config": {
"name": "dropout_3",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"rate": 0.5,
"seed": null,
"noise_shape": null
}
},
{
"class_name": "Dense",
"config": {
"name": "dense_1",
"trainable": true,
"dtype": {
"module": "keras",
"class_name": "DTypePolicy",
"config": { "name": "float32" },
"registered_name": null
},
"units": 10,
"activation": "softmax",
"use_bias": true,
"kernel_initializer": {
"module": "keras.initializers",
"class_name": "GlorotUniform",
"config": { "seed": null },
"registered_name": null
},
"bias_initializer": {
"module": "keras.initializers",
"class_name": "Zeros",
"config": {},
"registered_name": null
},
"kernel_regularizer": null,
"bias_regularizer": null,
"kernel_constraint": null,
"bias_constraint": null,
"quantization_config": null
}
}
],
"build_input_shape": [null, 128, 64, 1]
}
},
"training_config": {
"loss": "sparse_categorical_crossentropy",
"loss_weights": null,
"metrics": ["accuracy"],
"weighted_metrics": null,
"run_eagerly": false,
"steps_per_execution": 1,
"jit_compile": false,
"optimizer_config": {
"class_name": "Adam",
"config": {
"name": "adam",
"learning_rate": 0.0010000000474974513,
"weight_decay": null,
"clipnorm": null,
"global_clipnorm": null,
"clipvalue": null,
"use_ema": false,
"ema_momentum": 0.99,
"ema_overwrite_frequency": null,
"loss_scale_factor": null,
"gradient_accumulation_steps": null,
"beta_1": 0.9,
"beta_2": 0.999,
"epsilon": 1e-7,
"amsgrad": false
}
}
}
},
"weightsManifest": [
{
"paths": ["group1-shard1of1.bin"],
"weights": [
{
"name": "batch_normalization/gamma",
"shape": [32],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.970035195350647,
"scale": 0.00039288062675326476,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization/beta",
"shape": [32],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.04866361422281639,
"scale": 0.00040217862994063134,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization/moving_mean",
"shape": [32],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 1.0939256753772497e-5,
"scale": 0.001048501559268391,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization/moving_variance",
"shape": [32],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.000532817910425365,
"scale": 0.00016297123568388176,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_1/gamma",
"shape": [64],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.9726127982139587,
"scale": 0.00019898110744999905,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_1/beta",
"shape": [64],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.06264814909766703,
"scale": 0.00037290564939087515,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_1/moving_mean",
"shape": [64],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.12544548511505127,
"scale": 0.001907470179539101,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_1/moving_variance",
"shape": [64],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.042508192360401154,
"scale": 0.002489794206385519,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_2/gamma",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.975760817527771,
"scale": 0.0003113854165170707,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_2/beta",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.023137448749998037,
"scale": 0.00013072004943501716,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_2/moving_mean",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.015866611152887344,
"scale": 0.005222073358063605,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_2/moving_variance",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.01432291604578495,
"scale": 0.00944612571860061,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_3/gamma",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.9765098690986633,
"scale": 0.0008689317048764697,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_3/beta",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.05253423078387391,
"scale": 0.00032833894239921196,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_3/moving_mean",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 2.3402893845059225e-8,
"scale": 0.124165194550534,
"original_dtype": "float32"
}
},
{
"name": "batch_normalization_3/moving_variance",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": 0.000532600621227175,
"scale": 0.8092722632006888,
"original_dtype": "float32"
}
},
{
"name": "conv2d/kernel",
"shape": [3, 3, 1, 32],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.1684967933916578,
"scale": 0.0012961291799358293,
"original_dtype": "float32"
}
},
{
"name": "conv2d/bias",
"shape": [32],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.014791351323034248,
"scale": 0.00019462304372413485,
"original_dtype": "float32"
}
},
{
"name": "conv2d_1/kernel",
"shape": [3, 3, 32, 64],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.14185832411635155,
"scale": 0.0010912178778180888,
"original_dtype": "float32"
}
},
{
"name": "conv2d_1/bias",
"shape": [64],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.052345379924072934,
"scale": 0.00033341006321065564,
"original_dtype": "float32"
}
},
{
"name": "conv2d_2/kernel",
"shape": [3, 3, 64, 128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.09215074052997664,
"scale": 0.0007199276603904425,
"original_dtype": "float32"
}
},
{
"name": "conv2d_2/bias",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.052666782806901374,
"scale": 0.00035346834098591524,
"original_dtype": "float32"
}
},
{
"name": "dense/kernel",
"shape": [16384, 128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.1078803108311167,
"scale": 0.0006960020053620432,
"original_dtype": "float32"
}
},
{
"name": "dense/bias",
"shape": [128],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.010696043731535184,
"scale": 0.00013539295862702763,
"original_dtype": "float32"
}
},
{
"name": "dense_1/kernel",
"shape": [128, 10],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.26071277062098186,
"scale": 0.002190863618663713,
"original_dtype": "float32"
}
},
{
"name": "dense_1/bias",
"shape": [10],
"dtype": "float32",
"quantization": {
"dtype": "uint8",
"min": -0.020677046455881174,
"scale": 0.00016028718182853623,
"original_dtype": "float32"
}
}
]
}
]
}

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

File diff suppressed because one or more lines are too long

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

View File

@@ -0,0 +1,24 @@
/**
* Column Classifier Training Data Generator
*
* This module generates synthetic training data for the TensorFlow.js
* abacus column digit classifier used by AbacusVisionBridge.
*
* Usage:
* npx tsx scripts/train-column-classifier/generateTrainingData.ts
*
* See README.md for full documentation.
*/
export * from './types'
export {
renderColumnSVG,
generateAllDigitSVGs,
getColumnDimensions,
} from './renderColumn'
export {
SeededRandom,
augmentImage,
generateAugmentedBatch,
type AugmentationResult,
} from './augmentation'

View File

@@ -0,0 +1,75 @@
/**
* Render single-column abacus SVGs for training data generation
*
* Uses AbacusStatic from @soroban/abacus-react for consistent rendering
*/
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { AbacusStatic } from '@soroban/abacus-react'
import type { AbacusStyleVariant } from './types'
/**
* Render a single column showing a digit (0-9)
*
* @param digit - The digit to display (0-9)
* @param style - The visual style configuration
* @returns SVG string
*/
export function renderColumnSVG(digit: number, style: AbacusStyleVariant): string {
if (digit < 0 || digit > 9) {
throw new Error(`Digit must be 0-9, got ${digit}`)
}
const element = (
<AbacusStatic
value={digit}
columns={1}
beadShape={style.beadShape}
colorScheme={style.colorScheme}
scaleFactor={style.scaleFactor}
showNumbers={false}
frameVisible={true}
hideInactiveBeads={false}
/>
)
return renderToStaticMarkup(element)
}
/**
* Generate all digit SVGs for a given style
*
* @param style - The visual style configuration
* @returns Map of digit -> SVG string
*/
export function generateAllDigitSVGs(style: AbacusStyleVariant): Map<number, string> {
const svgMap = new Map<number, string>()
for (let digit = 0; digit <= 9; digit++) {
svgMap.set(digit, renderColumnSVG(digit, style))
}
return svgMap
}
/**
* Calculate SVG dimensions for a single column
*
* @param scaleFactor - Scale factor to apply
* @returns { width, height } in pixels
*/
export function getColumnDimensions(scaleFactor: number = 1): {
width: number
height: number
} {
// Base dimensions for a single column (from calculateStandardDimensions)
// These are approximate values; actual values come from the shared dimension calculator
const baseRodSpacing = 50
const baseHeight = 180 // Approximate height for 1 heaven + 4 earth beads
return {
width: Math.round(baseRodSpacing * scaleFactor),
height: Math.round(baseHeight * scaleFactor),
}
}

View File

@@ -0,0 +1,15 @@
# Python dependencies for training the abacus column classifier
#
# Install with:
# pip install -r scripts/train-column-classifier/requirements.txt
#
# Or create a virtual environment:
# python -m venv .venv
# source .venv/bin/activate
# pip install -r scripts/train-column-classifier/requirements.txt
tensorflow>=2.15.0
tensorflowjs>=4.0.0
numpy>=1.24.0
Pillow>=10.0.0
scikit-learn>=1.3.0

View File

@@ -0,0 +1,371 @@
#!/usr/bin/env python3
"""
Train a CNN classifier for abacus column digit recognition.
This script:
1. Loads training images from the generated dataset
2. Trains a lightweight CNN (target: <2MB when quantized)
3. Exports to TensorFlow.js format
Usage:
python scripts/train-column-classifier/train_model.py [options]
Options:
--data-dir DIR Training data directory (default: ./training-data/column-classifier)
--output-dir DIR Output directory for model (default: ./public/models/abacus-column-classifier)
--epochs N Number of training epochs (default: 50)
--batch-size N Batch size (default: 32)
--validation-split Validation split ratio (default: 0.2)
--no-augmentation Disable runtime augmentation
"""
import argparse
import json
import os
import sys
from pathlib import Path
import numpy as np
def parse_args():
parser = argparse.ArgumentParser(description="Train abacus column classifier")
parser.add_argument(
"--data-dir",
type=str,
default="./training-data/column-classifier",
help="Training data directory",
)
parser.add_argument(
"--output-dir",
type=str,
default="./public/models/abacus-column-classifier",
help="Output directory for model",
)
parser.add_argument(
"--epochs", type=int, default=50, help="Number of training epochs"
)
parser.add_argument("--batch-size", type=int, default=32, help="Batch size")
parser.add_argument(
"--validation-split",
type=float,
default=0.2,
help="Validation split ratio",
)
parser.add_argument(
"--no-augmentation",
action="store_true",
help="Disable runtime augmentation",
)
return parser.parse_args()
def load_dataset(data_dir: str):
"""Load images and labels from the dataset directory."""
from PIL import Image
images = []
labels = []
data_path = Path(data_dir)
if not data_path.exists():
print(f"Error: Data directory not found: {data_dir}")
print("Run the data generation script first:")
print(" npx tsx scripts/train-column-classifier/generateTrainingData.ts")
sys.exit(1)
# Load metadata
metadata_path = data_path / "metadata.json"
if metadata_path.exists():
with open(metadata_path) as f:
metadata = json.load(f)
print(f"Dataset info:")
print(f" Generated: {metadata.get('generatedAt', 'unknown')}")
print(f" Total samples: {metadata.get('totalSamples', 'unknown')}")
print(f" Image size: {metadata['config']['outputWidth']}x{metadata['config']['outputHeight']}")
# Load images from each digit directory
for digit in range(10):
digit_dir = data_path / str(digit)
if not digit_dir.exists():
print(f"Warning: Missing digit directory: {digit_dir}")
continue
digit_images = list(digit_dir.glob("*.png"))
print(f" Digit {digit}: {len(digit_images)} images")
for img_path in digit_images:
try:
img = Image.open(img_path).convert("L") # Grayscale
img_array = np.array(img, dtype=np.float32) / 255.0
images.append(img_array)
labels.append(digit)
except Exception as e:
print(f"Error loading {img_path}: {e}")
if not images:
print("Error: No images loaded")
sys.exit(1)
# Convert to numpy arrays
X = np.array(images)
y = np.array(labels)
# Add channel dimension (for grayscale: H, W, 1)
X = X[..., np.newaxis]
print(f"\nLoaded {len(X)} images")
print(f"Input shape: {X.shape}")
print(f"Label distribution: {np.bincount(y)}")
return X, y
def create_model(input_shape=(128, 64, 1), num_classes=10):
"""Create a lightweight CNN for digit classification."""
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
model = keras.Sequential([
# Input layer
keras.Input(shape=input_shape),
# Block 1: 32 filters
layers.Conv2D(32, (3, 3), activation="relu", padding="same"),
layers.BatchNormalization(),
layers.MaxPooling2D((2, 2)),
layers.Dropout(0.25),
# Block 2: 64 filters
layers.Conv2D(64, (3, 3), activation="relu", padding="same"),
layers.BatchNormalization(),
layers.MaxPooling2D((2, 2)),
layers.Dropout(0.25),
# Block 3: 128 filters
layers.Conv2D(128, (3, 3), activation="relu", padding="same"),
layers.BatchNormalization(),
layers.MaxPooling2D((2, 2)),
layers.Dropout(0.25),
# Dense layers
layers.Flatten(),
layers.Dense(128, activation="relu"),
layers.BatchNormalization(),
layers.Dropout(0.5),
layers.Dense(num_classes, activation="softmax"),
])
model.compile(
optimizer=keras.optimizers.Adam(learning_rate=0.001),
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
return model
def create_augmentation_layer():
"""Create data augmentation layer for runtime augmentation."""
import tensorflow as tf
from tensorflow.keras import layers
return tf.keras.Sequential([
layers.RandomRotation(0.05), # ±5% of 360° = ±18°
layers.RandomZoom(0.1), # ±10%
layers.RandomBrightness(0.1), # ±10%
])
def train_model(
X_train,
y_train,
X_val,
y_val,
epochs=50,
batch_size=32,
use_augmentation=True,
):
"""Train the model with optional data augmentation."""
import tensorflow as tf
from tensorflow import keras
# Create model
input_shape = X_train.shape[1:]
model = create_model(input_shape=input_shape)
model.summary()
# Create augmentation if enabled
if use_augmentation:
augmentation = create_augmentation_layer()
# Create augmented training dataset
train_ds = tf.data.Dataset.from_tensor_slices((X_train, y_train))
train_ds = train_ds.shuffle(len(X_train))
train_ds = train_ds.map(
lambda x, y: (augmentation(x, training=True), y),
num_parallel_calls=tf.data.AUTOTUNE,
)
train_ds = train_ds.batch(batch_size).prefetch(tf.data.AUTOTUNE)
val_ds = tf.data.Dataset.from_tensor_slices((X_val, y_val))
val_ds = val_ds.batch(batch_size).prefetch(tf.data.AUTOTUNE)
else:
train_ds = tf.data.Dataset.from_tensor_slices((X_train, y_train))
train_ds = train_ds.shuffle(len(X_train)).batch(batch_size).prefetch(tf.data.AUTOTUNE)
val_ds = tf.data.Dataset.from_tensor_slices((X_val, y_val))
val_ds = val_ds.batch(batch_size).prefetch(tf.data.AUTOTUNE)
# Callbacks
callbacks = [
keras.callbacks.EarlyStopping(
monitor="val_accuracy",
patience=10,
restore_best_weights=True,
),
keras.callbacks.ReduceLROnPlateau(
monitor="val_loss",
factor=0.5,
patience=5,
min_lr=1e-6,
),
]
# Train
history = model.fit(
train_ds,
validation_data=val_ds,
epochs=epochs,
callbacks=callbacks,
verbose=1,
)
return model, history
def export_to_tfjs(model, output_dir: str):
"""Export model to TensorFlow.js format with quantization."""
import tensorflowjs as tfjs
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# Export with quantization for smaller model size
tfjs.converters.save_keras_model(
model,
str(output_path),
quantization_dtype_map={"uint8": "*"}, # Quantize weights to uint8
)
print(f"\nModel exported to: {output_path}")
# Check model size
model_json = output_path / "model.json"
weights_bin = list(output_path.glob("*.bin"))
total_size = model_json.stat().st_size
for w in weights_bin:
total_size += w.stat().st_size
print(f"Model size: {total_size / 1024 / 1024:.2f} MB")
if total_size > 2 * 1024 * 1024:
print("Warning: Model exceeds 2MB target size")
def save_keras_model(model, output_dir: str):
"""Save Keras model for potential further training."""
output_path = Path(output_dir)
keras_path = output_path / "column-classifier.keras"
model.save(keras_path)
print(f"Keras model saved to: {keras_path}")
def main():
args = parse_args()
print("=" * 60)
print("Abacus Column Classifier Training")
print("=" * 60)
# Check TensorFlow is available
try:
import tensorflow as tf
print(f"TensorFlow version: {tf.__version__}")
# Check for GPU
gpus = tf.config.list_physical_devices("GPU")
if gpus:
print(f"GPU available: {len(gpus)} device(s)")
else:
print("No GPU detected, using CPU")
except ImportError:
print("Error: TensorFlow not installed")
print("Install with: pip install tensorflow")
sys.exit(1)
# Check tensorflowjs is available (optional - can convert later)
tfjs_available = False
try:
import tensorflowjs
print(f"TensorFlow.js converter version: {tensorflowjs.__version__}")
tfjs_available = True
except (ImportError, AttributeError) as e:
print(f"Note: tensorflowjs not available ({type(e).__name__})")
print("Model will be saved as Keras format. Convert later with:")
print(" tensorflowjs_converter --input_format=keras model.keras output_dir/")
print()
# Load dataset
print("Loading dataset...")
X, y = load_dataset(args.data_dir)
# Split into train/validation
from sklearn.model_selection import train_test_split
X_train, X_val, y_train, y_val = train_test_split(
X, y, test_size=args.validation_split, stratify=y, random_state=42
)
print(f"\nTraining set: {len(X_train)} samples")
print(f"Validation set: {len(X_val)} samples")
# Train model
print("\nTraining model...")
model, history = train_model(
X_train,
y_train,
X_val,
y_val,
epochs=args.epochs,
batch_size=args.batch_size,
use_augmentation=not args.no_augmentation,
)
# Evaluate final accuracy
val_loss, val_acc = model.evaluate(X_val, y_val, verbose=0)
print(f"\nFinal validation accuracy: {val_acc * 100:.2f}%")
if val_acc < 0.95:
print("Warning: Accuracy below 95% target")
# Save Keras model
save_keras_model(model, args.output_dir)
# Export to TensorFlow.js (if available)
if tfjs_available:
print("\nExporting to TensorFlow.js format...")
export_to_tfjs(model, args.output_dir)
else:
print("\nSkipping TensorFlow.js export (tensorflowjs not available)")
print("Convert later with:")
print(f" tensorflowjs_converter --input_format=keras {args.output_dir}/column-classifier.keras {args.output_dir}")
print("\nTraining complete!")
print(f"Model files saved to: {args.output_dir}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,144 @@
/**
* Types for synthetic training data generation
*/
export interface AugmentationConfig {
/** Rotation range in degrees [-angle, +angle] */
rotationRange: number
/** Scale range [minScale, maxScale] */
scaleRange: [number, number]
/** Brightness range [minBrightness, maxBrightness] where 1.0 = no change */
brightnessRange: [number, number]
/** Gaussian noise standard deviation (0-255) */
noiseStdDev: number
/** Background color variations (array of CSS colors) */
backgroundColors: string[]
/** Probability of adding blur (0-1) */
blurProbability: number
/** Max blur radius in pixels */
maxBlurRadius: number
}
export interface GenerationConfig {
/** Number of samples per digit (0-9) */
samplesPerDigit: number
/** Output image width in pixels */
outputWidth: number
/** Output image height in pixels */
outputHeight: number
/** Output format */
format: 'png' | 'jpeg'
/** Quality for jpeg (0-100) */
quality: number
/** Augmentation settings */
augmentation: AugmentationConfig
/** Output directory */
outputDir: string
/** Random seed for reproducibility (optional) */
seed?: number
}
export interface GeneratedSample {
/** File path to the generated image */
filePath: string
/** Digit represented (0-9) */
digit: number
/** Applied augmentation parameters */
augmentation: {
rotation: number
scale: number
brightness: number
noiseApplied: boolean
backgroundColor: string
blurRadius: number
}
}
export interface GenerationProgress {
/** Total samples to generate */
total: number
/** Samples generated so far */
completed: number
/** Current digit being generated */
currentDigit: number
/** Errors encountered */
errors: string[]
}
export interface AbacusStyleVariant {
/** Variant name */
name: string
/** Bead shape */
beadShape: 'circle' | 'diamond' | 'square'
/** Color scheme */
colorScheme: 'monochrome' | 'place-value' | 'alternating' | 'heaven-earth'
/** Scale factor */
scaleFactor: number
}
export const DEFAULT_AUGMENTATION: AugmentationConfig = {
rotationRange: 5, // ±5 degrees
scaleRange: [0.9, 1.1],
brightnessRange: [0.8, 1.2],
noiseStdDev: 10,
backgroundColors: [
'#ffffff', // white
'#f5f5f5', // light gray
'#fafafa', // off-white
'#fff8e7', // cream
'#f0f9ff', // light blue tint
'#f0fdf4', // light green tint
'#fefce8', // light yellow tint
],
blurProbability: 0.1,
maxBlurRadius: 1.5,
}
export const DEFAULT_GENERATION_CONFIG: GenerationConfig = {
samplesPerDigit: 5000,
outputWidth: 64,
outputHeight: 128,
format: 'png',
quality: 90,
augmentation: DEFAULT_AUGMENTATION,
outputDir: './training-data/column-classifier',
}
export const ABACUS_STYLE_VARIANTS: AbacusStyleVariant[] = [
{
name: 'circle-mono',
beadShape: 'circle',
colorScheme: 'monochrome',
scaleFactor: 1.0,
},
{
name: 'diamond-mono',
beadShape: 'diamond',
colorScheme: 'monochrome',
scaleFactor: 1.0,
},
{
name: 'square-mono',
beadShape: 'square',
colorScheme: 'monochrome',
scaleFactor: 1.0,
},
{
name: 'circle-heaven-earth',
beadShape: 'circle',
colorScheme: 'heaven-earth',
scaleFactor: 1.0,
},
{
name: 'diamond-heaven-earth',
beadShape: 'diamond',
colorScheme: 'heaven-earth',
scaleFactor: 1.0,
},
{
name: 'circle-place-value',
beadShape: 'circle',
colorScheme: 'place-value',
scaleFactor: 1.0,
},
]

View File

@@ -42,7 +42,13 @@ async function fetchAggregateBktStats(threshold: number): Promise<{
}> {
const res = await api(`settings/bkt/aggregate?threshold=${threshold}`)
if (!res.ok) {
return { totalStudents: 0, totalSkills: 0, struggling: 0, learning: 0, mastered: 0 }
return {
totalStudents: 0,
totalSkills: 0,
struggling: 0,
learning: 0,
mastered: 0,
}
}
return res.json()
}
@@ -136,7 +142,12 @@ export function BktSettingsClient({ students }: BktSettingsClientProps) {
>
BKT Confidence Threshold
</h1>
<p className={css({ color: isDark ? 'gray.400' : 'gray.600', marginTop: '0.5rem' })}>
<p
className={css({
color: isDark ? 'gray.400' : 'gray.600',
marginTop: '0.5rem',
})}
>
Configure how much evidence is required before trusting skill classifications.
</p>
</header>
@@ -166,12 +177,21 @@ export function BktSettingsClient({ students }: BktSettingsClientProps) {
Confidence Threshold
</span>
<span
className={css({ fontSize: '0.875rem', color: isDark ? 'gray.400' : 'gray.600' })}
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Higher values require more practice data before classifying skills.
</span>
</label>
<div className={css({ display: 'flex', alignItems: 'center', gap: '1rem' })}>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '1rem',
})}
>
<input
type="range"
min="0.1"
@@ -180,7 +200,10 @@ export function BktSettingsClient({ students }: BktSettingsClientProps) {
value={effectiveThreshold}
onChange={(e) => handleSliderChange(Number(e.target.value))}
disabled={isLoadingSettings}
className={css({ flex: 1, accentColor: isDark ? 'blue.400' : 'blue.600' })}
className={css({
flex: 1,
accentColor: isDark ? 'blue.400' : 'blue.600',
})}
/>
<span
className={css({
@@ -209,7 +232,13 @@ export function BktSettingsClient({ students }: BktSettingsClientProps) {
</div>
{/* Save/Reset buttons */}
<div className={css({ display: 'flex', gap: '0.75rem', alignItems: 'center' })}>
<div
className={css({
display: 'flex',
gap: '0.75rem',
alignItems: 'center',
})}
>
<button
type="button"
onClick={handleSave}
@@ -274,7 +303,13 @@ export function BktSettingsClient({ students }: BktSettingsClientProps) {
</h2>
{/* View mode toggle */}
<div className={css({ display: 'flex', gap: '0.5rem', marginBottom: '1rem' })}>
<div
className={css({
display: 'flex',
gap: '0.5rem',
marginBottom: '1rem',
})}
>
<button
type="button"
onClick={() => setViewMode('aggregate')}
@@ -352,7 +387,12 @@ export function BktSettingsClient({ students }: BktSettingsClientProps) {
/>
</BktProvider>
) : (
<p className={css({ color: isDark ? 'gray.500' : 'gray.500', fontStyle: 'italic' })}>
<p
className={css({
color: isDark ? 'gray.500' : 'gray.500',
fontStyle: 'italic',
})}
>
Select a student to preview their skill classifications.
</p>
)}
@@ -442,14 +482,25 @@ function AggregatePreview({
if (!stats || stats.totalStudents === 0) {
return (
<p className={css({ color: isDark ? 'gray.500' : 'gray.500', fontStyle: 'italic' })}>
<p
className={css({
color: isDark ? 'gray.500' : 'gray.500',
fontStyle: 'italic',
})}
>
No students with practice data found.
</p>
)
}
return (
<div className={css({ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '1rem' })}>
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '1rem',
})}
>
<StatCard label="Total Skills" value={stats.totalSkills} color="blue" isDark={isDark} />
<StatCard label="Weak" value={stats.struggling} color="red" isDark={isDark} />
<StatCard label="Developing" value={stats.learning} color="yellow" isDark={isDark} />
@@ -490,7 +541,12 @@ function StudentPreview({
if (!hasData) {
return (
<p className={css({ color: isDark ? 'gray.500' : 'gray.500', fontStyle: 'italic' })}>
<p
className={css({
color: isDark ? 'gray.500' : 'gray.500',
fontStyle: 'italic',
})}
>
{studentName} has no practice data yet.
</p>
)
@@ -498,10 +554,21 @@ function StudentPreview({
return (
<div>
<p className={css({ marginBottom: '1rem', color: isDark ? 'gray.300' : 'gray.700' })}>
<p
className={css({
marginBottom: '1rem',
color: isDark ? 'gray.300' : 'gray.700',
})}
>
{studentName}'s skills at {(previewThreshold * 100).toFixed(0)}% confidence:
</p>
<div className={css({ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' })}>
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
})}
>
<StatCard label="Weak" value={struggling.length} color="red" isDark={isDark} />
<StatCard label="Developing" value={learning.length} color="yellow" isDark={isDark} />
<StatCard label="Strong" value={mastered.length} color="green" isDark={isDark} />
@@ -555,13 +622,22 @@ function StatCard({
isDark: boolean
}) {
const colorMap = {
blue: { bg: isDark ? 'blue.900/50' : 'blue.50', text: isDark ? 'blue.300' : 'blue.700' },
red: { bg: isDark ? 'red.900/50' : 'red.50', text: isDark ? 'red.300' : 'red.700' },
blue: {
bg: isDark ? 'blue.900/50' : 'blue.50',
text: isDark ? 'blue.300' : 'blue.700',
},
red: {
bg: isDark ? 'red.900/50' : 'red.50',
text: isDark ? 'red.300' : 'red.700',
},
yellow: {
bg: isDark ? 'yellow.900/50' : 'yellow.50',
text: isDark ? 'yellow.300' : 'yellow.700',
},
green: { bg: isDark ? 'green.900/50' : 'green.50', text: isDark ? 'green.300' : 'green.700' },
green: {
bg: isDark ? 'green.900/50' : 'green.50',
text: isDark ? 'green.300' : 'green.700',
},
}
return (
@@ -582,7 +658,12 @@ function StatCard({
>
{value}
</div>
<div className={css({ fontSize: '0.75rem', color: isDark ? 'gray.400' : 'gray.600' })}>
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
{label}
</div>
</div>

View File

@@ -0,0 +1,139 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import {
createEnrollmentRequest,
getLinkedParentIds,
getTeacherClassroom,
isEnrolled,
} from '@/lib/classroom'
import { getSocketIO } from '@/lib/socket-io'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string }>
}
/**
* POST /api/classrooms/[classroomId]/enroll-by-family-code
* Teacher looks up a student by family code and creates an enrollment request
*
* Body: { familyCode: string }
* Returns: { success: true, request, player } or { success: false, error }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const body = await req.json()
if (!body.familyCode) {
return NextResponse.json({ success: false, error: 'Missing familyCode' }, { status: 400 })
}
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(user.id)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ success: false, error: 'Not authorized' }, { status: 403 })
}
// Look up player by family code
const normalizedCode = body.familyCode.toUpperCase().trim()
const player = await db.query.players.findFirst({
where: eq(schema.players.familyCode, normalizedCode),
})
if (!player) {
return NextResponse.json(
{ success: false, error: 'No student found with that family code' },
{ status: 404 }
)
}
// Check if already enrolled
const alreadyEnrolled = await isEnrolled(classroomId, player.id)
if (alreadyEnrolled) {
return NextResponse.json(
{
success: false,
error: 'This student is already enrolled in your classroom',
},
{ status: 400 }
)
}
// Create enrollment request (teacher-initiated, requires parent approval)
const request = await createEnrollmentRequest({
classroomId,
playerId: player.id,
requestedBy: user.id,
requestedByRole: 'teacher',
})
// Emit socket event for real-time updates
const io = await getSocketIO()
if (io) {
try {
const eventData = {
request: {
id: request.id,
classroomId,
classroomName: classroom.name,
playerId: player.id,
playerName: player.name,
requestedByRole: 'teacher',
},
}
// Emit to classroom channel (for teacher's view)
io.to(`classroom:${classroomId}`).emit('enrollment-request-created', eventData)
console.log(
`[Enroll by Family Code API] Teacher created enrollment request for ${player.name}`
)
// Also emit to parent's user channel so they see the pending approval
const parentIds = await getLinkedParentIds(player.id)
for (const parentId of parentIds) {
io.to(`user:${parentId}`).emit('enrollment-request-created', eventData)
console.log(`[Enroll by Family Code API] Notified parent ${parentId} of new request`)
}
} catch (socketError) {
console.error('[Enroll by Family Code API] Failed to broadcast:', socketError)
}
}
return NextResponse.json({
success: true,
request,
player: {
id: player.id,
name: player.name,
emoji: player.emoji,
color: player.color,
},
})
} catch (error) {
console.error('Failed to enroll by family code:', error)
return NextResponse.json(
{ success: false, error: 'Failed to create enrollment request' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,107 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { approveEnrollmentRequest, getLinkedParentIds, getTeacherClassroom } from '@/lib/classroom'
import {
emitEnrollmentCompleted,
emitEnrollmentRequestApproved,
} from '@/lib/classroom/socket-emitter'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string; requestId: string }>
}
/**
* POST /api/classrooms/[classroomId]/enrollment-requests/[requestId]/approve
* Teacher approves enrollment request
*
* Returns: { request, enrolled: boolean }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId, requestId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(user.id)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const result = await approveEnrollmentRequest(requestId, user.id, 'teacher')
// Emit socket events for real-time updates
try {
// Get classroom and player info for socket events
const [classroomInfo] = await db
.select({ name: schema.classrooms.name })
.from(schema.classrooms)
.where(eq(schema.classrooms.id, classroomId))
.limit(1)
const [playerInfo] = await db
.select({ name: schema.players.name })
.from(schema.players)
.where(eq(schema.players.id, result.request.playerId))
.limit(1)
if (classroomInfo && playerInfo) {
// Get parent IDs to notify
const parentIds = await getLinkedParentIds(result.request.playerId)
const payload = {
requestId,
classroomId,
classroomName: classroomInfo.name,
playerId: result.request.playerId,
playerName: playerInfo.name,
}
if (result.fullyApproved) {
// Both sides approved - notify everyone
await emitEnrollmentCompleted(payload, {
classroomId, // Teacher sees the update
userIds: parentIds, // Parents see the update
playerIds: [result.request.playerId], // Student's enrolled classrooms list updates
})
} else {
// Only teacher approved - notify parent that their part is done
// This happens when the request was parent-initiated
await emitEnrollmentRequestApproved(
{ ...payload, approvedBy: 'teacher' },
{ userIds: parentIds }
)
}
}
} catch (socketError) {
console.error('[Teacher Approve] Failed to emit socket event:', socketError)
}
return NextResponse.json({
request: result.request,
enrolled: result.fullyApproved,
})
} catch (error) {
console.error('Failed to approve enrollment request:', error)
const message = error instanceof Error ? error.message : 'Failed to approve enrollment request'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,89 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { denyEnrollmentRequest, getLinkedParentIds, getTeacherClassroom } from '@/lib/classroom'
import { emitEnrollmentRequestDenied } from '@/lib/classroom/socket-emitter'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string; requestId: string }>
}
/**
* POST /api/classrooms/[classroomId]/enrollment-requests/[requestId]/deny
* Teacher denies enrollment request
*
* Returns: { request }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId, requestId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(user.id)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const request = await denyEnrollmentRequest(requestId, user.id, 'teacher')
// Emit socket event for real-time updates
try {
// Get classroom and player info for socket event
const [classroomInfo] = await db
.select({ name: schema.classrooms.name })
.from(schema.classrooms)
.where(eq(schema.classrooms.id, classroomId))
.limit(1)
const [playerInfo] = await db
.select({ name: schema.players.name })
.from(schema.players)
.where(eq(schema.players.id, request.playerId))
.limit(1)
if (classroomInfo && playerInfo) {
// Get parent IDs to notify
const parentIds = await getLinkedParentIds(request.playerId)
await emitEnrollmentRequestDenied(
{
requestId,
classroomId,
classroomName: classroomInfo.name,
playerId: request.playerId,
playerName: playerInfo.name,
deniedBy: 'teacher',
},
{ userIds: parentIds }
)
}
} catch (socketError) {
console.error('[Teacher Deny] Failed to emit socket event:', socketError)
}
return NextResponse.json({ request })
} catch (error) {
console.error('Failed to deny enrollment request:', error)
const message = error instanceof Error ? error.message : 'Failed to deny enrollment request'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,160 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import {
createEnrollmentRequest,
getLinkedParentIds,
getPendingRequestsForClassroom,
getRequestsAwaitingParentApproval,
getTeacherClassroom,
isParent,
} from '@/lib/classroom'
import { getSocketIO } from '@/lib/socket-io'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string }>
}
/**
* GET /api/classrooms/[classroomId]/enrollment-requests
* Get pending enrollment requests (teacher only)
*
* Returns:
* - requests: Requests needing teacher approval (parent-initiated)
* - awaitingParentApproval: Requests needing parent approval (teacher-initiated)
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(user.id)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Fetch both types of pending requests in parallel
const [requests, awaitingParentApproval] = await Promise.all([
getPendingRequestsForClassroom(classroomId),
getRequestsAwaitingParentApproval(classroomId),
])
return NextResponse.json({ requests, awaitingParentApproval })
} catch (error) {
console.error('Failed to fetch enrollment requests:', error)
return NextResponse.json({ error: 'Failed to fetch enrollment requests' }, { status: 500 })
}
}
/**
* POST /api/classrooms/[classroomId]/enrollment-requests
* Create enrollment request (parent or teacher)
*
* Body: { playerId: string }
* Returns: { request }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const body = await req.json()
if (!body.playerId) {
return NextResponse.json({ error: 'Missing playerId' }, { status: 400 })
}
// Determine role: is user the teacher or a parent?
const classroom = await getTeacherClassroom(user.id)
const isTeacher = classroom?.id === classroomId
const parentCheck = await isParent(user.id, body.playerId)
if (!isTeacher && !parentCheck) {
return NextResponse.json(
{ error: 'Must be the classroom teacher or a parent of the student' },
{ status: 403 }
)
}
const requestedByRole = isTeacher ? 'teacher' : 'parent'
const request = await createEnrollmentRequest({
classroomId,
playerId: body.playerId,
requestedBy: user.id,
requestedByRole,
})
// Get classroom and player info for the socket event
const [classroomInfo] = await db
.select({ name: schema.classrooms.name })
.from(schema.classrooms)
.where(eq(schema.classrooms.id, classroomId))
.limit(1)
const [playerInfo] = await db
.select({ name: schema.players.name })
.from(schema.players)
.where(eq(schema.players.id, body.playerId))
.limit(1)
// Emit socket event to the classroom channel for real-time updates
const io = await getSocketIO()
if (io && classroomInfo && playerInfo) {
try {
const eventData = {
request: {
id: request.id,
classroomId,
classroomName: classroomInfo.name,
playerId: body.playerId,
playerName: playerInfo.name,
requestedByRole,
},
}
// Emit to classroom channel (for teacher's view)
io.to(`classroom:${classroomId}`).emit('enrollment-request-created', eventData)
console.log(
`[Enrollment Request API] Emitted enrollment-request-created for classroom ${classroomId}`
)
// If teacher-initiated, also emit to parent's user channel
// so they see the new pending approval in real-time
if (requestedByRole === 'teacher') {
const parentIds = await getLinkedParentIds(body.playerId)
for (const parentId of parentIds) {
io.to(`user:${parentId}`).emit('enrollment-request-created', eventData)
console.log(`[Enrollment Request API] Notified parent ${parentId} of new request`)
}
}
} catch (socketError) {
console.error('[Enrollment Request API] Failed to broadcast request:', socketError)
}
}
return NextResponse.json({ request }, { status: 201 })
} catch (error) {
console.error('Failed to create enrollment request:', error)
return NextResponse.json({ error: 'Failed to create enrollment request' }, { status: 500 })
}
}

View File

@@ -0,0 +1,97 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getLinkedParentIds, getTeacherClassroom, isParent, unenrollStudent } from '@/lib/classroom'
import { emitStudentUnenrolled } from '@/lib/classroom/socket-emitter'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string; playerId: string }>
}
/**
* DELETE /api/classrooms/[classroomId]/enrollments/[playerId]
* Unenroll student from classroom (teacher or parent)
*
* Returns: { success: true }
*/
export async function DELETE(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId, playerId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Check authorization: must be teacher of classroom OR parent of student
const classroom = await getTeacherClassroom(user.id)
const isTeacher = classroom?.id === classroomId
const parentCheck = await isParent(user.id, playerId)
if (!isTeacher && !parentCheck) {
return NextResponse.json(
{ error: 'Must be the classroom teacher or a parent of the student' },
{ status: 403 }
)
}
await unenrollStudent(classroomId, playerId)
// Emit socket event for real-time updates
try {
// Get classroom and player info for socket event
const [classroomInfo] = await db
.select({ name: schema.classrooms.name })
.from(schema.classrooms)
.where(eq(schema.classrooms.id, classroomId))
.limit(1)
const [playerInfo] = await db
.select({ name: schema.players.name })
.from(schema.players)
.where(eq(schema.players.id, playerId))
.limit(1)
if (classroomInfo && playerInfo) {
// Get parent IDs to notify
const parentIds = await getLinkedParentIds(playerId)
await emitStudentUnenrolled(
{
classroomId,
classroomName: classroomInfo.name,
playerId,
playerName: playerInfo.name,
unenrolledBy: isTeacher ? 'teacher' : 'parent',
},
{
classroomId, // Teacher sees student removed
userIds: parentIds, // Parents see child is no longer enrolled
playerIds: [playerId], // Student sees they're no longer in classroom
}
)
}
} catch (socketError) {
console.error('[Unenroll] Failed to emit socket event:', socketError)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to unenroll student:', error)
return NextResponse.json({ error: 'Failed to unenroll student' }, { status: 500 })
}
}

View File

@@ -0,0 +1,127 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { directEnrollStudent, getEnrolledStudents, getTeacherClassroom } from '@/lib/classroom'
import { emitEnrollmentCompleted } from '@/lib/classroom/socket-emitter'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string }>
}
/**
* GET /api/classrooms/[classroomId]/enrollments
* Get all enrolled students (teacher only)
*
* Returns: { students: Player[] }
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(user.id)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const students = await getEnrolledStudents(classroomId)
return NextResponse.json({ students })
} catch (error) {
console.error('Failed to fetch enrolled students:', error)
return NextResponse.json({ error: 'Failed to fetch enrolled students' }, { status: 500 })
}
}
/**
* POST /api/classrooms/[classroomId]/enrollments
* Directly enroll a student (teacher only, bypasses request workflow)
*
* Body: { playerId: string }
* Returns: { enrolled: boolean }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(user.id)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const body = await req.json()
const { playerId } = body
if (!playerId) {
return NextResponse.json({ error: 'playerId is required' }, { status: 400 })
}
// Verify the player exists and belongs to this teacher
const player = await db.query.players.findFirst({
where: eq(schema.players.id, playerId),
})
if (!player) {
return NextResponse.json({ error: 'Player not found' }, { status: 404 })
}
// Verify teacher owns this player
if (player.userId !== user.id) {
return NextResponse.json(
{ error: 'Can only directly enroll students you created' },
{ status: 403 }
)
}
// Directly enroll the student
const enrolled = await directEnrollStudent(classroomId, playerId)
if (enrolled) {
// Emit socket event for real-time updates
try {
await emitEnrollmentCompleted(
{
classroomId,
classroomName: classroom.name,
playerId,
playerName: player.name,
},
{
classroomId,
userIds: [], // No parents to notify since teacher created this student
playerIds: [playerId],
}
)
} catch (socketError) {
console.error('[DirectEnroll] Failed to emit socket event:', socketError)
}
}
return NextResponse.json({ enrolled })
} catch (error) {
console.error('Failed to directly enroll student:', error)
return NextResponse.json({ error: 'Failed to enroll student' }, { status: 500 })
}
}

View File

@@ -0,0 +1,199 @@
import { and, eq, inArray, lt } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import {
getEnrolledStudents,
getLinkedParentIds,
getPresentPlayerIds,
getTeacherClassroom,
} from '@/lib/classroom'
import { emitEntryPromptCreated } from '@/lib/classroom/socket-emitter'
import { getDbUserId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ classroomId: string }>
}
/**
* Default expiry time for entry prompts (30 minutes)
*/
const DEFAULT_EXPIRY_MINUTES = 30
/**
* GET /api/classrooms/[classroomId]/entry-prompts
* Get pending entry prompts for the classroom (teacher only)
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const userId = await getDbUserId()
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(userId)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get pending prompts for this classroom
const prompts = await db.query.entryPrompts.findMany({
where: and(
eq(schema.entryPrompts.classroomId, classroomId),
eq(schema.entryPrompts.status, 'pending')
),
})
// Filter out expired prompts (client-side check)
const now = new Date()
const activePrompts = prompts.filter((p) => p.expiresAt > now)
return NextResponse.json({ prompts: activePrompts })
} catch (error) {
console.error('Failed to fetch entry prompts:', error)
return NextResponse.json({ error: 'Failed to fetch entry prompts' }, { status: 500 })
}
}
/**
* POST /api/classrooms/[classroomId]/entry-prompts
* Create entry prompts for students (teacher only)
*
* Body: { playerIds: string[], expiresInMinutes?: number }
* Returns: { prompts: EntryPrompt[], skipped: { playerId: string, reason: string }[] }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const userId = await getDbUserId()
const body = await req.json()
// Validate request body
if (!body.playerIds || !Array.isArray(body.playerIds) || body.playerIds.length === 0) {
return NextResponse.json({ error: 'Missing or invalid playerIds' }, { status: 400 })
}
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(userId)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get teacher's name for the notification
const teacher = await db.query.users.findFirst({
where: eq(schema.users.id, userId),
})
const teacherName = teacher?.name || 'Your teacher'
// Get enrolled students for this classroom
const enrolledStudents = await getEnrolledStudents(classroomId)
const enrolledPlayerIds = new Set(enrolledStudents.map((s) => s.id))
// Get currently present students
const presentPlayerIds = new Set(await getPresentPlayerIds(classroomId))
// Mark any expired pending prompts as 'expired' so unique constraint allows new ones
const now = new Date()
await db
.update(schema.entryPrompts)
.set({ status: 'expired' })
.where(
and(
eq(schema.entryPrompts.classroomId, classroomId),
eq(schema.entryPrompts.status, 'pending'),
inArray(schema.entryPrompts.playerId, body.playerIds),
lt(schema.entryPrompts.expiresAt, now) // Only mark actually expired prompts
)
)
// Now query for any truly active (non-expired) pending prompts
const existingPrompts = await db.query.entryPrompts.findMany({
where: and(
eq(schema.entryPrompts.classroomId, classroomId),
eq(schema.entryPrompts.status, 'pending'),
inArray(schema.entryPrompts.playerId, body.playerIds)
),
})
// Filter to only active prompts (not expired)
const activeExistingPrompts = existingPrompts.filter((p) => p.expiresAt > now)
const existingPromptPlayerIds = new Set(activeExistingPrompts.map((p) => p.playerId))
// Calculate expiry time (request override > classroom setting > system default)
const expiresInMinutes =
body.expiresInMinutes || classroom.entryPromptExpiryMinutes || DEFAULT_EXPIRY_MINUTES
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000)
// Process each player
const createdPrompts: (typeof schema.entryPrompts.$inferSelect)[] = []
const skipped: { playerId: string; reason: string }[] = []
for (const playerId of body.playerIds) {
// Check if enrolled
if (!enrolledPlayerIds.has(playerId)) {
skipped.push({ playerId, reason: 'not_enrolled' })
continue
}
// Check if already present
if (presentPlayerIds.has(playerId)) {
skipped.push({ playerId, reason: 'already_present' })
continue
}
// Check if already has pending prompt
if (existingPromptPlayerIds.has(playerId)) {
skipped.push({ playerId, reason: 'pending_prompt_exists' })
continue
}
// Create the entry prompt
const [prompt] = await db
.insert(schema.entryPrompts)
.values({
teacherId: userId,
playerId,
classroomId,
expiresAt,
})
.returning()
createdPrompts.push(prompt)
// Get player info for the notification
const player = await db.query.players.findFirst({
where: eq(schema.players.id, playerId),
})
if (player) {
// Get parent IDs to notify
const parentIds = await getLinkedParentIds(playerId)
// Emit socket event to parents
await emitEntryPromptCreated(
{
promptId: prompt.id,
classroomId,
classroomName: classroom.name,
playerId,
playerName: player.name,
playerEmoji: player.emoji,
teacherName,
expiresAt,
},
parentIds
)
}
}
return NextResponse.json(
{
prompts: createdPrompts,
skipped,
created: createdPrompts.length,
skippedCount: skipped.length,
},
{ status: 201 }
)
} catch (error) {
console.error('Failed to create entry prompts:', error)
return NextResponse.json({ error: 'Failed to create entry prompts' }, { status: 500 })
}
}

View File

@@ -0,0 +1,59 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { leaveSpecificClassroom, getTeacherClassroom, isParent } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string; playerId: string }>
}
/**
* DELETE /api/classrooms/[classroomId]/presence/[playerId]
* Remove student from classroom (teacher or parent)
*
* Returns: { success: true }
*/
export async function DELETE(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId, playerId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Check authorization: must be teacher of classroom OR parent of student
const classroom = await getTeacherClassroom(user.id)
const isTeacher = classroom?.id === classroomId
const parentCheck = await isParent(user.id, playerId)
if (!isTeacher && !parentCheck) {
return NextResponse.json(
{ error: 'Must be the classroom teacher or a parent of the student' },
{ status: 403 }
)
}
// Pass 'teacher' if removed by teacher, 'self' otherwise (parent removing their child)
await leaveSpecificClassroom(playerId, classroomId, isTeacher ? 'teacher' : 'self')
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to remove student from classroom:', error)
return NextResponse.json({ error: 'Failed to remove student from classroom' }, { status: 500 })
}
}

View File

@@ -0,0 +1,128 @@
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getClassroomPresence, getEnrolledStudents, getTeacherClassroom } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string }>
}
/**
* Active session information returned by this endpoint
*/
interface ActiveSessionInfo {
/** Session plan ID (for observation) */
sessionId: string
/** Player ID */
playerId: string
/** When the session started */
startedAt: Date
/** Current part index */
currentPartIndex: number
/** Current slot index within the part */
currentSlotIndex: number
/** Total parts in session */
totalParts: number
/** Total problems in session (sum of all slots) */
totalProblems: number
/** Number of completed problems */
completedProblems: number
/** Whether the student is currently present in the classroom */
isPresent: boolean
}
/**
* GET /api/classrooms/[classroomId]/presence/active-sessions
* Get active practice sessions for enrolled students in the classroom
*
* Returns: { sessions: ActiveSessionInfo[] }
*
* This endpoint allows teachers to see which students are actively practicing.
* It returns sessions for ALL enrolled students, not just present ones.
* The `isPresent` field indicates whether the teacher can observe the session.
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(user.id)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get all enrolled students in the classroom
const enrolledStudents = await getEnrolledStudents(classroomId)
const enrolledPlayerIds = enrolledStudents.map((s) => s.id)
if (enrolledPlayerIds.length === 0) {
return NextResponse.json({ sessions: [] })
}
// Get presence info to know which students are present
const presences = await getClassroomPresence(classroomId)
const presentPlayerIds = new Set(
presences.filter((p) => p.player !== undefined).map((p) => p.player!.id)
)
// Find active sessions for enrolled students
// Active = status is 'in_progress', startedAt is set, completedAt is null
const activeSessions = await db.query.sessionPlans.findMany({
where: and(
inArray(schema.sessionPlans.playerId, enrolledPlayerIds),
eq(schema.sessionPlans.status, 'in_progress'),
isNull(schema.sessionPlans.completedAt)
),
})
// Map to ActiveSessionInfo
const sessions: ActiveSessionInfo[] = activeSessions
.filter((session) => session.startedAt)
.map((session) => {
// Calculate total and completed problems
const parts = session.parts
const totalProblems = parts.reduce((sum, part) => sum + part.slots.length, 0)
let completedProblems = 0
for (let i = 0; i < session.currentPartIndex; i++) {
completedProblems += parts[i].slots.length
}
completedProblems += session.currentSlotIndex
return {
sessionId: session.id,
playerId: session.playerId,
startedAt: session.startedAt as Date,
currentPartIndex: session.currentPartIndex,
currentSlotIndex: session.currentSlotIndex,
totalParts: parts.length,
totalProblems,
completedProblems,
isPresent: presentPlayerIds.has(session.playerId),
}
})
return NextResponse.json({ sessions })
} catch (error) {
console.error('Failed to fetch active sessions:', error)
return NextResponse.json({ error: 'Failed to fetch active sessions' }, { status: 500 })
}
}

View File

@@ -0,0 +1,117 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import {
enterClassroom,
getClassroomPresence,
getTeacherClassroom,
isParent,
} from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string }>
}
/**
* GET /api/classrooms/[classroomId]/presence
* Get all students currently present in classroom (teacher only)
*
* Returns: { students: Player[] }
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(user.id)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const presences = await getClassroomPresence(classroomId)
// Return players with presence info
return NextResponse.json({
students: presences.map((p) => ({
...p.player,
enteredAt: p.enteredAt,
enteredBy: p.enteredBy,
})),
})
} catch (error) {
console.error('Failed to fetch classroom presence:', error)
return NextResponse.json({ error: 'Failed to fetch classroom presence' }, { status: 500 })
}
}
/**
* POST /api/classrooms/[classroomId]/presence
* Enter student into classroom (teacher or parent)
*
* Body: { playerId: string }
* Returns: { success: true, presence } or { success: false, error }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const body = await req.json()
if (!body.playerId) {
return NextResponse.json({ success: false, error: 'Missing playerId' }, { status: 400 })
}
// Check authorization: must be teacher of classroom OR parent of student
const classroom = await getTeacherClassroom(user.id)
const isTeacher = classroom?.id === classroomId
const parentCheck = await isParent(user.id, body.playerId)
if (!isTeacher && !parentCheck) {
return NextResponse.json(
{
success: false,
error: 'Must be the classroom teacher or a parent of the student',
},
{ status: 403 }
)
}
const result = await enterClassroom({
playerId: body.playerId,
classroomId,
enteredBy: user.id,
})
if (!result.success) {
return NextResponse.json({ success: false, error: result.error }, { status: 400 })
}
return NextResponse.json({ success: true, presence: result.presence })
} catch (error) {
console.error('Failed to enter classroom:', error)
return NextResponse.json(
{ success: false, error: 'Failed to enter classroom' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,134 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import {
deleteClassroom,
getClassroom,
updateClassroom,
regenerateClassroomCode,
} from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string }>
}
/**
* GET /api/classrooms/[classroomId]
* Get classroom by ID
*
* Returns: { classroom } or 404
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const classroom = await getClassroom(classroomId)
if (!classroom) {
return NextResponse.json({ error: 'Classroom not found' }, { status: 404 })
}
return NextResponse.json({ classroom })
} catch (error) {
console.error('Failed to fetch classroom:', error)
return NextResponse.json({ error: 'Failed to fetch classroom' }, { status: 500 })
}
}
/**
* PATCH /api/classrooms/[classroomId]
* Update classroom settings (teacher only)
*
* Body: { name?: string, regenerateCode?: boolean, entryPromptExpiryMinutes?: number | null }
* Returns: { classroom }
*/
export async function PATCH(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const body = await req.json()
// Handle code regeneration separately
if (body.regenerateCode) {
const newCode = await regenerateClassroomCode(classroomId, user.id)
if (!newCode) {
return NextResponse.json(
{ error: 'Not authorized or classroom not found' },
{ status: 403 }
)
}
// Fetch updated classroom
const classroom = await getClassroom(classroomId)
return NextResponse.json({ classroom })
}
// Update other fields
const updates: { name?: string; entryPromptExpiryMinutes?: number | null } = {}
if (body.name) updates.name = body.name
// Allow setting to null (use system default) or a positive number
if ('entryPromptExpiryMinutes' in body) {
const value = body.entryPromptExpiryMinutes
if (value === null || (typeof value === 'number' && value > 0)) {
updates.entryPromptExpiryMinutes = value
}
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 })
}
const classroom = await updateClassroom(classroomId, user.id, updates)
if (!classroom) {
return NextResponse.json({ error: 'Not authorized or classroom not found' }, { status: 403 })
}
return NextResponse.json({ classroom })
} catch (error) {
console.error('Failed to update classroom:', error)
return NextResponse.json({ error: 'Failed to update classroom' }, { status: 500 })
}
}
/**
* DELETE /api/classrooms/[classroomId]
* Delete classroom (teacher only, cascades enrollments)
*
* Returns: { success: true }
*/
export async function DELETE(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const success = await deleteClassroom(classroomId, user.id)
if (!success) {
return NextResponse.json({ error: 'Not authorized or classroom not found' }, { status: 403 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete classroom:', error)
return NextResponse.json({ error: 'Failed to delete classroom' }, { status: 500 })
}
}

View File

@@ -0,0 +1,42 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getClassroomByCode } from '@/lib/classroom'
interface RouteParams {
params: Promise<{ code: string }>
}
/**
* GET /api/classrooms/code/[code]
* Look up classroom by join code
*
* Returns: { classroom, teacher } or 404
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { code } = await params
const classroom = await getClassroomByCode(code)
if (!classroom) {
return NextResponse.json({ error: 'Classroom not found' }, { status: 404 })
}
return NextResponse.json({
classroom: {
id: classroom.id,
name: classroom.name,
code: classroom.code,
createdAt: classroom.createdAt,
},
teacher: classroom.teacher
? {
id: classroom.teacher.id,
name: classroom.teacher.name,
}
: null,
})
} catch (error) {
console.error('Failed to lookup classroom:', error)
return NextResponse.json({ error: 'Failed to lookup classroom' }, { status: 500 })
}
}

View File

@@ -0,0 +1,45 @@
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getTeacherClassroom } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
/**
* GET /api/classrooms/mine
* Get current user's classroom (if teacher)
*
* Returns: { classroom } or 404
*/
export async function GET() {
try {
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const classroom = await getTeacherClassroom(user.id)
if (!classroom) {
return NextResponse.json({ error: 'No classroom found' }, { status: 404 })
}
return NextResponse.json({ classroom })
} catch (error) {
console.error('Failed to fetch classroom:', error)
return NextResponse.json({ error: 'Failed to fetch classroom' }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { createClassroom, getTeacherClassroom } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
/**
* GET /api/classrooms
* Get current user's classroom (alias for /api/classrooms/mine)
*
* Returns: { classroom } or { classroom: null }
*/
export async function GET() {
try {
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const classroom = await getTeacherClassroom(user.id)
return NextResponse.json({ classroom })
} catch (error) {
console.error('Failed to fetch classroom:', error)
return NextResponse.json({ error: 'Failed to fetch classroom' }, { status: 500 })
}
}
/**
* POST /api/classrooms
* Create a classroom for current user (becomes teacher)
*
* Body: { name: string }
* Returns: { success: true, classroom } or { success: false, error }
*/
export async function POST(req: NextRequest) {
try {
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const body = await req.json()
if (!body.name) {
return NextResponse.json({ success: false, error: 'Missing name' }, { status: 400 })
}
const result = await createClassroom({
teacherId: user.id,
name: body.name,
})
if (!result.success) {
return NextResponse.json({ success: false, error: result.error }, { status: 400 })
}
return NextResponse.json({ success: true, classroom: result.classroom }, { status: 201 })
} catch (error) {
console.error('Failed to create classroom:', error)
return NextResponse.json(
{ success: false, error: 'Failed to create classroom' },
{ status: 500 }
)
}
}

View File

@@ -5,7 +5,9 @@
*/
import { NextResponse } from 'next/server'
import { canPerformAction } from '@/lib/classroom'
import { advanceToNextPhase } from '@/lib/curriculum/progress-manager'
import { getDbUserId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ playerId: string }>
@@ -22,6 +24,13 @@ export async function POST(request: Request, { params }: RouteParams) {
return NextResponse.json({ error: 'Player ID required' }, { status: 400 })
}
// Authorization check
const userId = await getDbUserId()
const canView = await canPerformAction(userId, playerId, 'view')
if (!canView) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const body = await request.json()
const { nextPhaseId, nextLevel } = body

View File

@@ -9,7 +9,9 @@
*/
import { NextResponse } from 'next/server'
import { canPerformAction } from '@/lib/classroom'
import { getSkillAnomalies } from '@/lib/curriculum/skill-unlock'
import { getDbUserId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ playerId: string }>
@@ -26,6 +28,13 @@ export async function GET(_request: Request, { params }: RouteParams) {
return NextResponse.json({ error: 'Player ID required' }, { status: 400 })
}
// Authorization check
const userId = await getDbUserId()
const canView = await canPerformAction(userId, playerId, 'view')
if (!canView) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const anomalies = await getSkillAnomalies(playerId)
return NextResponse.json({

View File

@@ -0,0 +1,169 @@
/**
* API route for approving parsed worksheet results and adding to existing session
*
* POST /api/curriculum/[playerId]/attachments/[attachmentId]/approve
* - Approves the parsing result
* - Adds the parsed problems to the EXISTING session that the attachment belongs to
* - Does NOT create a new session - attachments are already associated with sessions
*/
import { NextResponse } from 'next/server'
import { eq } from 'drizzle-orm'
import { db } from '@/db'
import { practiceAttachments } from '@/db/schema/practice-attachments'
import { sessionPlans, type SlotResult } from '@/db/schema/session-plans'
import { canPerformAction } from '@/lib/classroom'
import { getDbUserId } from '@/lib/viewer'
import { convertToSlotResults, computeParsingStats } from '@/lib/worksheet-parsing'
interface RouteParams {
params: Promise<{ playerId: string; attachmentId: string }>
}
/**
* POST - Approve parsing and add problems to existing session
*/
export async function POST(_request: Request, { params }: RouteParams) {
try {
const { playerId, attachmentId } = await params
if (!playerId || !attachmentId) {
return NextResponse.json({ error: 'Player ID and Attachment ID required' }, { status: 400 })
}
// Authorization check
const userId = await getDbUserId()
const canApprove = await canPerformAction(userId, playerId, 'start-session')
if (!canApprove) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get attachment record
const attachment = await db
.select()
.from(practiceAttachments)
.where(eq(practiceAttachments.id, attachmentId))
.get()
if (!attachment) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
if (attachment.playerId !== playerId) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
// Check if already processed
if (attachment.sessionCreated) {
return NextResponse.json(
{
error: 'Problems from this worksheet already added to session',
},
{ status: 400 }
)
}
// Get the existing session that this attachment belongs to
const existingSession = await db
.select()
.from(sessionPlans)
.where(eq(sessionPlans.id, attachment.sessionId))
.get()
if (!existingSession) {
return NextResponse.json(
{
error: 'Associated session not found',
},
{ status: 404 }
)
}
// Get the parsing result to convert (prefer approved result, fall back to raw)
const parsingResult = attachment.approvedResult ?? attachment.rawParsingResult
if (!parsingResult) {
return NextResponse.json(
{
error: 'No parsing results available. Parse the worksheet first.',
},
{ status: 400 }
)
}
// Convert to slot results
// Always use part 1 for offline worksheets - slot indices track individual problems
const conversionResult = convertToSlotResults(parsingResult, {
partNumber: 1,
source: 'practice',
})
if (conversionResult.slotResults.length === 0) {
return NextResponse.json(
{
error: 'No valid problems to add to session',
},
{ status: 400 }
)
}
const now = new Date()
// Add timestamps to slot results and adjust slot indices
const existingResults = (existingSession.results ?? []) as SlotResult[]
const startSlotIndex = existingResults.length
const slotResultsWithTimestamps: SlotResult[] = conversionResult.slotResults.map(
(result, idx) => ({
...result,
slotIndex: startSlotIndex + idx,
timestamp: now,
})
)
// Merge new results with existing results
const mergedResults = [...existingResults, ...slotResultsWithTimestamps]
// Calculate updated stats
const totalCount = mergedResults.length
const correctCount = mergedResults.filter((r) => r.isCorrect).length
// Update the existing session with the new problems
await db
.update(sessionPlans)
.set({
results: mergedResults,
// Update the completed timestamp since we added new work
completedAt: now,
// Mark as completed if it wasn't already
status: 'completed',
})
.where(eq(sessionPlans.id, existingSession.id))
// Update attachment to mark as processed
await db
.update(practiceAttachments)
.set({
parsingStatus: 'approved',
sessionCreated: true,
createdSessionId: existingSession.id, // Reference to the session we added to
})
.where(eq(practiceAttachments.id, attachmentId))
// Compute final stats
const stats = computeParsingStats(parsingResult)
return NextResponse.json({
success: true,
sessionId: existingSession.id,
problemCount: slotResultsWithTimestamps.length,
totalSessionProblems: totalCount,
correctCount,
accuracy: totalCount > 0 ? correctCount / totalCount : null,
skillsExercised: conversionResult.skillsExercised,
stats,
})
} catch (error) {
console.error('Error approving and adding to session:', error)
return NextResponse.json({ error: 'Failed to approve and add to session' }, { status: 500 })
}
}

View File

@@ -0,0 +1,89 @@
/**
* API route for serving practice attachment files
*
* GET /api/curriculum/[playerId]/attachments/[attachmentId]/file
*
* Serves the actual image file for a practice attachment.
* Authorization is checked to ensure only parents and teachers can access.
*/
import { readFile, stat } from 'fs/promises'
import { NextResponse } from 'next/server'
import { join } from 'path'
import { eq } from 'drizzle-orm'
import { db } from '@/db'
import { practiceAttachments } from '@/db/schema'
import { canPerformAction } from '@/lib/classroom'
import { getDbUserId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ playerId: string; attachmentId: string }>
}
/**
* GET - Serve attachment file
*/
export async function GET(_request: Request, { params }: RouteParams) {
try {
const { playerId, attachmentId } = await params
if (!playerId || !attachmentId) {
return NextResponse.json({ error: 'Player ID and Attachment ID required' }, { status: 400 })
}
// Authorization check
const userId = await getDbUserId()
const canView = await canPerformAction(userId, playerId, 'view')
if (!canView) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get attachment record
const attachment = await db
.select()
.from(practiceAttachments)
.where(eq(practiceAttachments.id, attachmentId))
.get()
if (!attachment) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
// Verify the attachment belongs to the specified player
if (attachment.playerId !== playerId) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
// Build file path
const filepath = join(
process.cwd(),
'data',
'uploads',
'players',
playerId,
attachment.filename
)
// Check if file exists
try {
await stat(filepath)
} catch {
console.error(`Attachment file not found: ${filepath}`)
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
// Read and serve file
const fileBuffer = await readFile(filepath)
return new NextResponse(new Uint8Array(fileBuffer), {
headers: {
'Content-Type': attachment.mimeType,
'Content-Length': attachment.fileSize.toString(),
'Cache-Control': 'private, max-age=31536000', // Cache for 1 year (files are immutable)
},
})
} catch (error) {
console.error('Error serving attachment:', error)
return NextResponse.json({ error: 'Failed to serve attachment' }, { status: 500 })
}
}

View File

@@ -0,0 +1,116 @@
/**
* API route for serving original (uncropped) practice attachment files
*
* GET /api/curriculum/[playerId]/attachments/[attachmentId]/original
*
* Serves the original uncropped image file for a practice attachment.
* If no original exists (legacy attachments or skipped crop), falls back
* to the regular cropped file.
*
* Used when re-editing photos to start from the full original image
* rather than cropping an already-cropped copy.
*/
import { readFile, stat } from 'fs/promises'
import { NextResponse } from 'next/server'
import { join } from 'path'
import { eq } from 'drizzle-orm'
import { db } from '@/db'
import { practiceAttachments } from '@/db/schema'
import { canPerformAction } from '@/lib/classroom'
import { getDbUserId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ playerId: string; attachmentId: string }>
}
/**
* GET - Serve original attachment file
*/
export async function GET(_request: Request, { params }: RouteParams) {
try {
const { playerId, attachmentId } = await params
if (!playerId || !attachmentId) {
return NextResponse.json({ error: 'Player ID and Attachment ID required' }, { status: 400 })
}
// Authorization check
const userId = await getDbUserId()
const canView = await canPerformAction(userId, playerId, 'view')
if (!canView) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get attachment record
const attachment = await db
.select()
.from(practiceAttachments)
.where(eq(practiceAttachments.id, attachmentId))
.get()
if (!attachment) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
// Verify the attachment belongs to the specified player
if (attachment.playerId !== playerId) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
// Use original filename if available, otherwise fall back to cropped file
const filename = attachment.originalFilename || attachment.filename
// Build file path
const filepath = join(process.cwd(), 'data', 'uploads', 'players', playerId, filename)
// Check if file exists
let fileStats
try {
fileStats = await stat(filepath)
} catch {
// If original file doesn't exist, fall back to cropped file
if (attachment.originalFilename) {
const fallbackPath = join(
process.cwd(),
'data',
'uploads',
'players',
playerId,
attachment.filename
)
try {
fileStats = await stat(fallbackPath)
// Use fallback path
const fileBuffer = await readFile(fallbackPath)
return new NextResponse(new Uint8Array(fileBuffer), {
headers: {
'Content-Type': attachment.mimeType,
'Content-Length': fileStats.size.toString(),
'Cache-Control': 'private, max-age=31536000',
},
})
} catch {
console.error(`Attachment file not found: ${fallbackPath}`)
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
}
console.error(`Attachment file not found: ${filepath}`)
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
// Read and serve file
const fileBuffer = await readFile(filepath)
return new NextResponse(new Uint8Array(fileBuffer), {
headers: {
'Content-Type': attachment.mimeType,
'Content-Length': fileStats.size.toString(),
'Cache-Control': 'private, max-age=31536000', // Cache for 1 year (files are immutable)
},
})
} catch (error) {
console.error('Error serving original attachment:', error)
return NextResponse.json({ error: 'Failed to serve attachment' }, { status: 500 })
}
}

View File

@@ -0,0 +1,323 @@
/**
* API route for selective problem re-parsing
*
* POST /api/curriculum/[playerId]/attachments/[attachmentId]/parse-selected
* - Re-parse specific problems by cropping their bounding boxes
* - Merges results back into existing parsing result
*/
import { readFile } from 'fs/promises'
import { NextResponse } from 'next/server'
import { join } from 'path'
import { eq } from 'drizzle-orm'
import sharp from 'sharp'
import { z } from 'zod'
import { db } from '@/db'
import { practiceAttachments } from '@/db/schema/practice-attachments'
import { canPerformAction } from '@/lib/classroom'
import { getDbUserId } from '@/lib/viewer'
import { llm } from '@/lib/llm'
import {
type ParsedProblem,
type BoundingBox,
type WorksheetParsingResult,
getModelConfig,
getDefaultModelConfig,
calculateCropRegion,
CROP_PADDING,
} from '@/lib/worksheet-parsing'
interface RouteParams {
params: Promise<{ playerId: string; attachmentId: string }>
}
// Schema for single problem re-parse response
const SingleProblemSchema = z.object({
terms: z
.array(z.number().int())
.min(2)
.max(7)
.describe(
'The terms (numbers) in this problem. First term is always positive. ' +
'Negative numbers indicate subtraction. Example: "45 - 17 + 8" -> [45, -17, 8]'
),
studentAnswer: z
.number()
.int()
.nullable()
.describe("The student's written answer. null if no answer is visible or answer box is empty."),
format: z
.enum(['vertical', 'linear'])
.describe('Format: "vertical" for stacked column, "linear" for horizontal'),
termsConfidence: z.number().min(0).max(1).describe('Confidence in terms reading (0-1)'),
studentAnswerConfidence: z
.number()
.min(0)
.max(1)
.describe('Confidence in student answer reading (0-1)'),
})
// Request body schema
const RequestBodySchema = z.object({
problemIndices: z.array(z.number().int().min(0)).min(1).max(20),
boundingBoxes: z.array(
z.object({
x: z.number().min(0).max(1),
y: z.number().min(0).max(1),
width: z.number().min(0).max(1),
height: z.number().min(0).max(1),
})
),
additionalContext: z.string().optional(),
modelConfigId: z.string().optional(),
})
/**
* Build prompt for single problem parsing
*/
function buildSingleProblemPrompt(additionalContext?: string): string {
let prompt = `You are analyzing a cropped image showing a SINGLE arithmetic problem from an abacus workbook.
Extract the following from this cropped problem image:
1. The problem terms (numbers being added/subtracted)
2. The student's written answer (if any)
3. The format (vertical or linear)
4. Your confidence in each reading
⚠️ **CRITICAL: MINUS SIGN DETECTION** ⚠️
Minus signs are SMALL but EXTREMELY IMPORTANT. Missing a minus sign completely changes the answer!
**How minus signs appear in VERTICAL problems:**
- A small horizontal dash/line to the LEFT of a number
- May appear as: (minus), - (hyphen), or a short horizontal stroke
- Often smaller than you expect - LOOK CAREFULLY!
- Sometimes positioned slightly above or below the number's vertical center
**Example - the ONLY difference is that tiny minus sign:**
- NO minus: 45 + 17 + 8 = 70 → terms = [45, 17, 8]
- WITH minus: 45 - 17 + 8 = 36 → terms = [45, -17, 8]
**You MUST examine the LEFT side of each number for minus signs!**
IMPORTANT:
- The first term is always positive
- Negative numbers indicate subtraction (e.g., "45 - 17" has terms [45, -17])
- If no student answer is visible, set studentAnswer to null
- Be precise about handwritten digits - common confusions: 1/7, 4/9, 6/0, 5/8
CONFIDENCE GUIDELINES:
- 0.9-1.0: Clear, unambiguous reading
- 0.7-0.89: Slightly unclear but confident
- 0.5-0.69: Uncertain, could be misread
- Below 0.5: Very uncertain`
if (additionalContext) {
prompt += `\n\nADDITIONAL CONTEXT FROM USER:\n${additionalContext}`
}
return prompt
}
/**
* Crop image to bounding box with padding using sharp (server-side).
* Uses shared calculateCropRegion for consistent cropping with client-side.
*/
async function cropToBoundingBox(
imageBuffer: Buffer,
box: BoundingBox,
padding: number = CROP_PADDING
): Promise<Buffer> {
const metadata = await sharp(imageBuffer).metadata()
const imageWidth = metadata.width ?? 1
const imageHeight = metadata.height ?? 1
// Use shared crop region calculation
const region = calculateCropRegion(box, imageWidth, imageHeight, padding)
return sharp(imageBuffer)
.extract({ left: region.left, top: region.top, width: region.width, height: region.height })
.toBuffer()
}
/**
* POST - Re-parse selected problems
*/
export async function POST(request: Request, { params }: RouteParams) {
try {
const { playerId, attachmentId } = await params
if (!playerId || !attachmentId) {
return NextResponse.json({ error: 'Player ID and Attachment ID required' }, { status: 400 })
}
// Parse request body
let body: z.infer<typeof RequestBodySchema>
try {
const rawBody = await request.json()
body = RequestBodySchema.parse(rawBody)
} catch (err) {
return NextResponse.json(
{ error: 'Invalid request body', details: err instanceof Error ? err.message : 'Unknown' },
{ status: 400 }
)
}
const { problemIndices, boundingBoxes, additionalContext, modelConfigId } = body
if (problemIndices.length !== boundingBoxes.length) {
return NextResponse.json(
{ error: 'problemIndices and boundingBoxes must have the same length' },
{ status: 400 }
)
}
// Resolve model config
const modelConfig = modelConfigId ? getModelConfig(modelConfigId) : getDefaultModelConfig()
// Authorization check
const userId = await getDbUserId()
const canParse = await canPerformAction(userId, playerId, 'start-session')
if (!canParse) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get attachment record
const attachment = await db
.select()
.from(practiceAttachments)
.where(eq(practiceAttachments.id, attachmentId))
.get()
if (!attachment) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
if (attachment.playerId !== playerId) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
// Must have existing parsing result to merge into
if (!attachment.rawParsingResult) {
return NextResponse.json({ error: 'Attachment has not been parsed yet' }, { status: 400 })
}
const existingResult = attachment.rawParsingResult as WorksheetParsingResult
// Read the image file
const uploadDir = join(process.cwd(), 'data', 'uploads', 'players', playerId)
const filepath = join(uploadDir, attachment.filename)
const imageBuffer = await readFile(filepath)
const mimeType = attachment.mimeType || 'image/jpeg'
// Build the prompt
const prompt = buildSingleProblemPrompt(additionalContext)
// Process each selected problem
const reparsedProblems: Array<{
index: number
originalProblem: ParsedProblem
newData: z.infer<typeof SingleProblemSchema>
}> = []
for (let i = 0; i < problemIndices.length; i++) {
const problemIndex = problemIndices[i]
const box = boundingBoxes[i]
const originalProblem = existingResult.problems[problemIndex]
if (!originalProblem) {
console.warn(`Problem index ${problemIndex} not found in existing result`)
continue
}
try {
// Crop image to bounding box
const croppedBuffer = await cropToBoundingBox(imageBuffer, box)
const base64Cropped = croppedBuffer.toString('base64')
const croppedDataUrl = `data:${mimeType};base64,${base64Cropped}`
// Call LLM for this problem
const response = await llm.vision({
prompt,
images: [croppedDataUrl],
schema: SingleProblemSchema,
maxRetries: 1,
provider: modelConfig?.provider,
model: modelConfig?.model,
reasoningEffort: modelConfig?.reasoningEffort,
})
reparsedProblems.push({
index: problemIndex,
originalProblem,
newData: response.data,
})
} catch (err) {
console.error(`Failed to re-parse problem ${problemIndex}:`, err)
// Continue with other problems
}
}
// Merge results back into existing parsing result
// Create a map from problem index to the user's adjusted bounding box
const adjustedBoxMap = new Map<number, BoundingBox>()
for (let i = 0; i < problemIndices.length; i++) {
adjustedBoxMap.set(problemIndices[i], boundingBoxes[i])
}
const updatedProblems = [...existingResult.problems]
for (const { index, originalProblem, newData } of reparsedProblems) {
const correctAnswer = newData.terms.reduce((a, b) => a + b, 0)
// Use the user's adjusted bounding box (passed in request), not the original
const userAdjustedBox = adjustedBoxMap.get(index) ?? originalProblem.problemBoundingBox
updatedProblems[index] = {
...originalProblem,
terms: newData.terms,
studentAnswer: newData.studentAnswer,
correctAnswer,
format: newData.format,
termsConfidence: newData.termsConfidence,
studentAnswerConfidence: newData.studentAnswerConfidence,
// Use the user's adjusted bounding box
problemBoundingBox: userAdjustedBox,
}
}
// Update the parsing result
const updatedResult: WorksheetParsingResult = {
...existingResult,
problems: updatedProblems,
// Recalculate overall confidence
overallConfidence:
updatedProblems.reduce(
(sum, p) => sum + Math.min(p.termsConfidence, p.studentAnswerConfidence),
0
) / updatedProblems.length,
// Check if any problems still need review
needsReview: updatedProblems.some(
(p) => Math.min(p.termsConfidence, p.studentAnswerConfidence) < 0.7
),
}
// Save updated result to database
await db
.update(practiceAttachments)
.set({
rawParsingResult: updatedResult,
confidenceScore: updatedResult.overallConfidence,
needsReview: updatedResult.needsReview,
parsingStatus: updatedResult.needsReview ? 'needs_review' : 'approved',
})
.where(eq(practiceAttachments.id, attachmentId))
return NextResponse.json({
success: true,
reparsedCount: reparsedProblems.length,
reparsedIndices: reparsedProblems.map((p) => p.index),
updatedResult,
})
} catch (error) {
console.error('Error in parse-selected:', error)
return NextResponse.json({ error: 'Failed to re-parse selected problems' }, { status: 500 })
}
}

View File

@@ -0,0 +1,311 @@
/**
* API route for LLM-powered worksheet parsing
*
* POST /api/curriculum/[playerId]/attachments/[attachmentId]/parse
* - Start parsing the attachment image
* - Returns immediately, polling via GET for status
*
* GET /api/curriculum/[playerId]/attachments/[attachmentId]/parse
* - Get current parsing status and results
*/
import { readFile } from 'fs/promises'
import { NextResponse } from 'next/server'
import { join } from 'path'
import { eq } from 'drizzle-orm'
import { db } from '@/db'
import { practiceAttachments, type ParsingStatus } from '@/db/schema/practice-attachments'
import { canPerformAction } from '@/lib/classroom'
import { getDbUserId } from '@/lib/viewer'
import {
parseWorksheetImage,
computeParsingStats,
buildWorksheetParsingPrompt,
getModelConfig,
getDefaultModelConfig,
type WorksheetParsingResult,
} from '@/lib/worksheet-parsing'
interface RouteParams {
params: Promise<{ playerId: string; attachmentId: string }>
}
/**
* POST - Start parsing the attachment
*
* Body (optional):
* - modelConfigId: string - ID of the model config to use (from PARSING_MODEL_CONFIGS)
* - additionalContext: string - Additional context/hints for the LLM
* - preservedBoundingBoxes: Record<number, BoundingBox> - Bounding boxes to preserve by index
*/
export async function POST(request: Request, { params }: RouteParams) {
try {
const { playerId, attachmentId } = await params
if (!playerId || !attachmentId) {
return NextResponse.json({ error: 'Player ID and Attachment ID required' }, { status: 400 })
}
// Parse optional parameters from request body
let modelConfigId: string | undefined
let additionalContext: string | undefined
let preservedBoundingBoxes:
| Record<number, { x: number; y: number; width: number; height: number }>
| undefined
try {
const body = await request.json()
modelConfigId = body?.modelConfigId
additionalContext = body?.additionalContext
preservedBoundingBoxes = body?.preservedBoundingBoxes
} catch {
// No body or invalid JSON is fine - use defaults
}
// Resolve model config
const modelConfig = modelConfigId ? getModelConfig(modelConfigId) : getDefaultModelConfig()
// Authorization check
const userId = await getDbUserId()
const canParse = await canPerformAction(userId, playerId, 'start-session')
if (!canParse) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get attachment record
const attachment = await db
.select()
.from(practiceAttachments)
.where(eq(practiceAttachments.id, attachmentId))
.get()
if (!attachment) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
if (attachment.playerId !== playerId) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
// Check if already processing
if (attachment.parsingStatus === 'processing') {
return NextResponse.json({
status: 'processing',
message: 'Parsing already in progress',
})
}
// Update status to processing
await db
.update(practiceAttachments)
.set({
parsingStatus: 'processing',
parsingError: null,
})
.where(eq(practiceAttachments.id, attachmentId))
// Read the image file
const uploadDir = join(process.cwd(), 'data', 'uploads', 'players', playerId)
const filepath = join(uploadDir, attachment.filename)
const imageBuffer = await readFile(filepath)
const base64Image = imageBuffer.toString('base64')
const mimeType = attachment.mimeType || 'image/jpeg'
const imageDataUrl = `data:${mimeType};base64,${base64Image}`
// Build the prompt (capture for debugging)
const promptOptions = additionalContext ? { additionalContext } : {}
const promptUsed = buildWorksheetParsingPrompt(promptOptions)
try {
// Parse the worksheet (always uses cropped image)
const result = await parseWorksheetImage(imageDataUrl, {
maxRetries: 2,
modelConfigId: modelConfig?.id,
promptOptions,
})
let parsingResult = result.data
// Merge preserved bounding boxes from user adjustments
// This allows the user's manual adjustments to be retained after re-parsing
if (preservedBoundingBoxes && Object.keys(preservedBoundingBoxes).length > 0) {
parsingResult = {
...parsingResult,
problems: parsingResult.problems.map((problem, index) => {
const preservedBox = preservedBoundingBoxes[index]
if (preservedBox) {
return {
...problem,
problemBoundingBox: preservedBox,
}
}
return problem
}),
}
}
const stats = computeParsingStats(parsingResult)
// Determine status based on confidence
const status: ParsingStatus = parsingResult.needsReview ? 'needs_review' : 'approved'
// Save results and LLM metadata to database
await db
.update(practiceAttachments)
.set({
parsingStatus: status,
parsedAt: new Date().toISOString(),
rawParsingResult: parsingResult,
confidenceScore: parsingResult.overallConfidence,
needsReview: parsingResult.needsReview,
parsingError: null,
// LLM metadata for debugging/transparency
llmProvider: result.provider,
llmModel: result.model,
llmPromptUsed: promptUsed,
llmRawResponse: result.rawResponse,
llmJsonSchema: result.jsonSchema,
llmImageSource: 'cropped',
llmAttempts: result.attempts,
llmPromptTokens: result.usage.promptTokens,
llmCompletionTokens: result.usage.completionTokens,
llmTotalTokens: result.usage.promptTokens + result.usage.completionTokens,
})
.where(eq(practiceAttachments.id, attachmentId))
return NextResponse.json({
success: true,
status,
result: parsingResult,
stats,
// LLM metadata in response
llm: {
provider: result.provider,
model: result.model,
attempts: result.attempts,
imageSource: 'cropped',
usage: result.usage,
},
})
} catch (parseError) {
const errorMessage =
parseError instanceof Error ? parseError.message : 'Unknown parsing error'
console.error('Worksheet parsing error:', parseError)
// Update status to failed
await db
.update(practiceAttachments)
.set({
parsingStatus: 'failed',
parsingError: errorMessage,
})
.where(eq(practiceAttachments.id, attachmentId))
return NextResponse.json(
{
success: false,
status: 'failed',
error: errorMessage,
},
{ status: 500 }
)
}
} catch (error) {
console.error('Error starting parse:', error)
return NextResponse.json({ error: 'Failed to start parsing' }, { status: 500 })
}
}
/**
* GET - Get parsing status and results
*/
export async function GET(_request: Request, { params }: RouteParams) {
try {
const { playerId, attachmentId } = await params
if (!playerId || !attachmentId) {
return NextResponse.json({ error: 'Player ID and Attachment ID required' }, { status: 400 })
}
// Authorization check
const userId = await getDbUserId()
const canView = await canPerformAction(userId, playerId, 'view')
if (!canView) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get attachment record
const attachment = await db
.select()
.from(practiceAttachments)
.where(eq(practiceAttachments.id, attachmentId))
.get()
if (!attachment) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
if (attachment.playerId !== playerId) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
// Build response based on status
const response: {
status: ParsingStatus | null
parsedAt: string | null
result: WorksheetParsingResult | null
error: string | null
needsReview: boolean
confidenceScore: number | null
stats?: ReturnType<typeof computeParsingStats>
llm?: {
provider: string | null
model: string | null
promptUsed: string | null
rawResponse: string | null
jsonSchema: string | null
imageSource: string | null
attempts: number | null
usage: {
promptTokens: number | null
completionTokens: number | null
totalTokens: number | null
}
}
} = {
status: attachment.parsingStatus,
parsedAt: attachment.parsedAt,
result: attachment.rawParsingResult,
error: attachment.parsingError,
needsReview: attachment.needsReview === true,
confidenceScore: attachment.confidenceScore,
}
// Add stats if we have results
if (attachment.rawParsingResult) {
response.stats = computeParsingStats(attachment.rawParsingResult)
}
// Add LLM metadata if available
if (attachment.llmProvider || attachment.llmModel) {
response.llm = {
provider: attachment.llmProvider,
model: attachment.llmModel,
promptUsed: attachment.llmPromptUsed,
rawResponse: attachment.llmRawResponse,
jsonSchema: attachment.llmJsonSchema,
imageSource: attachment.llmImageSource,
attempts: attachment.llmAttempts,
usage: {
promptTokens: attachment.llmPromptTokens,
completionTokens: attachment.llmCompletionTokens,
totalTokens: attachment.llmTotalTokens,
},
}
}
return NextResponse.json(response)
} catch (error) {
console.error('Error getting parse status:', error)
return NextResponse.json({ error: 'Failed to get parsing status' }, { status: 500 })
}
}

View File

@@ -0,0 +1,141 @@
/**
* API route for reviewing and correcting parsed worksheet results
*
* PATCH /api/curriculum/[playerId]/attachments/[attachmentId]/review
* - Submit user corrections to parsed problems
* - Updates the parsing result with corrections
*/
import { NextResponse } from 'next/server'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '@/db'
import { practiceAttachments, type ParsingStatus } from '@/db/schema/practice-attachments'
import { canPerformAction } from '@/lib/classroom'
import { getDbUserId } from '@/lib/viewer'
import {
applyCorrections,
computeParsingStats,
ProblemCorrectionSchema,
} from '@/lib/worksheet-parsing'
interface RouteParams {
params: Promise<{ playerId: string; attachmentId: string }>
}
/**
* Request body schema for corrections
*/
const ReviewRequestSchema = z.object({
corrections: z.array(ProblemCorrectionSchema).min(1),
markAsReviewed: z.boolean().default(false),
})
/**
* PATCH - Submit corrections to parsed problems
*/
export async function PATCH(request: Request, { params }: RouteParams) {
try {
const { playerId, attachmentId } = await params
if (!playerId || !attachmentId) {
return NextResponse.json({ error: 'Player ID and Attachment ID required' }, { status: 400 })
}
// Authorization check
const userId = await getDbUserId()
const canReview = await canPerformAction(userId, playerId, 'start-session')
if (!canReview) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Parse request body
const body = await request.json()
const parseResult = ReviewRequestSchema.safeParse(body)
if (!parseResult.success) {
return NextResponse.json(
{
error: 'Invalid request body',
details: parseResult.error.issues,
},
{ status: 400 }
)
}
const { corrections, markAsReviewed } = parseResult.data
// Get attachment record
const attachment = await db
.select()
.from(practiceAttachments)
.where(eq(practiceAttachments.id, attachmentId))
.get()
if (!attachment) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
if (attachment.playerId !== playerId) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
// Check if we have parsing results to correct
if (!attachment.rawParsingResult) {
return NextResponse.json(
{
error: 'No parsing results to correct. Parse the worksheet first.',
},
{ status: 400 }
)
}
// Apply corrections to the raw result
const correctedResult = applyCorrections(
attachment.rawParsingResult,
corrections.map((c) => ({
problemNumber: c.problemNumber,
correctedTerms: c.correctedTerms ?? undefined,
correctedStudentAnswer: c.correctedStudentAnswer ?? undefined,
shouldExclude: c.shouldExclude,
}))
)
// Compute new stats
const stats = computeParsingStats(correctedResult)
// Determine new status
let newStatus: ParsingStatus = attachment.parsingStatus ?? 'needs_review'
if (markAsReviewed) {
// If user explicitly marks as reviewed, set to approved
newStatus = 'approved'
} else if (!correctedResult.needsReview) {
// If all problems now have high confidence, auto-approve
newStatus = 'approved'
} else {
// Still needs review
newStatus = 'needs_review'
}
// Update database - store corrected result as approved result
await db
.update(practiceAttachments)
.set({
parsingStatus: newStatus,
approvedResult: correctedResult,
confidenceScore: correctedResult.overallConfidence,
needsReview: correctedResult.needsReview,
})
.where(eq(practiceAttachments.id, attachmentId))
return NextResponse.json({
success: true,
status: newStatus,
result: correctedResult,
stats,
correctionsApplied: corrections.length,
})
} catch (error) {
console.error('Error applying corrections:', error)
return NextResponse.json({ error: 'Failed to apply corrections' }, { status: 500 })
}
}

View File

@@ -0,0 +1,214 @@
/**
* API route for individual attachment operations
*
* PATCH /api/curriculum/[playerId]/attachments/[attachmentId]
* - Replace the cropped file with a new version (keeps original)
*
* DELETE /api/curriculum/[playerId]/attachments/[attachmentId]
* - Deletes a practice attachment (both DB record and files)
*
* Authorization is checked to ensure only parents and teachers can modify.
*/
import { randomUUID } from 'crypto'
import { mkdir, unlink, writeFile } from 'fs/promises'
import { NextResponse } from 'next/server'
import { join } from 'path'
import { eq } from 'drizzle-orm'
import { db } from '@/db'
import { practiceAttachments } from '@/db/schema'
import { canPerformAction } from '@/lib/classroom'
import { getDbUserId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ playerId: string; attachmentId: string }>
}
/**
* PATCH - Replace the cropped file with a new version
*
* Used when re-editing a photo. The original file is preserved,
* only the cropped/displayed version is replaced.
*/
export async function PATCH(request: Request, { params }: RouteParams) {
try {
const { playerId, attachmentId } = await params
if (!playerId || !attachmentId) {
return NextResponse.json({ error: 'Player ID and Attachment ID required' }, { status: 400 })
}
// Authorization check - require 'start-session' permission (parent or present teacher)
const userId = await getDbUserId()
const canEdit = await canPerformAction(userId, playerId, 'start-session')
if (!canEdit) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get existing attachment record
const attachment = await db
.select()
.from(practiceAttachments)
.where(eq(practiceAttachments.id, attachmentId))
.get()
if (!attachment) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
// Verify the attachment belongs to the specified player
if (attachment.playerId !== playerId) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
// Parse form data - expect a single 'file' (the new cropped version)
// and optionally 'corners' (JSON string of crop coordinates) and 'rotation'
const formData = await request.formData()
const file = formData.get('file')
const cornersStr = formData.get('corners')
const rotationStr = formData.get('rotation')
if (!(file instanceof File) || file.size === 0) {
return NextResponse.json({ error: 'File is required' }, { status: 400 })
}
// Parse corners if provided
let corners: Array<{ x: number; y: number }> | null = null
if (cornersStr && typeof cornersStr === 'string') {
try {
corners = JSON.parse(cornersStr) as Array<{ x: number; y: number }>
} catch {
// Invalid JSON, ignore corners
}
}
// Parse rotation if provided
let rotation: 0 | 90 | 180 | 270 = 0
if (rotationStr && typeof rotationStr === 'string') {
const parsed = parseInt(rotationStr, 10)
if (parsed === 0 || parsed === 90 || parsed === 180 || parsed === 270) {
rotation = parsed
}
}
// Validate file type
if (!file.type.startsWith('image/')) {
return NextResponse.json({ error: 'File must be an image' }, { status: 400 })
}
// Validate file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
return NextResponse.json({ error: 'File exceeds 10MB limit' }, { status: 400 })
}
// Ensure upload directory exists
const uploadDir = join(process.cwd(), 'data', 'uploads', 'players', playerId)
await mkdir(uploadDir, { recursive: true })
// Delete old cropped file (but NOT the original)
const oldFilepath = join(uploadDir, attachment.filename)
try {
await unlink(oldFilepath)
} catch {
// Ignore - file may already be gone
}
// Save new cropped file with new UUID
const extension = file.name.split('.').pop()?.toLowerCase() || 'jpg'
const newFilename = `${randomUUID()}.${extension}`
const newFilepath = join(uploadDir, newFilename)
const bytes = await file.arrayBuffer()
await writeFile(newFilepath, Buffer.from(bytes))
// Update database record with new filename, size, corners, and rotation
await db
.update(practiceAttachments)
.set({
filename: newFilename,
fileSize: file.size,
mimeType: file.type,
corners,
rotation,
})
.where(eq(practiceAttachments.id, attachmentId))
return NextResponse.json({
success: true,
attachmentId,
filename: newFilename,
fileSize: file.size,
rotation,
url: `/api/curriculum/${playerId}/attachments/${attachmentId}/file?v=${encodeURIComponent(newFilename)}`,
})
} catch (error) {
console.error('Error replacing attachment:', error)
return NextResponse.json({ error: 'Failed to replace attachment' }, { status: 500 })
}
}
/**
* DELETE - Delete an attachment
*/
export async function DELETE(_request: Request, { params }: RouteParams) {
try {
const { playerId, attachmentId } = await params
if (!playerId || !attachmentId) {
return NextResponse.json({ error: 'Player ID and Attachment ID required' }, { status: 400 })
}
// Authorization check - require 'start-session' permission (parent or present teacher)
const userId = await getDbUserId()
const canDelete = await canPerformAction(userId, playerId, 'start-session')
if (!canDelete) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get attachment record
const attachment = await db
.select()
.from(practiceAttachments)
.where(eq(practiceAttachments.id, attachmentId))
.get()
if (!attachment) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
// Verify the attachment belongs to the specified player
if (attachment.playerId !== playerId) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
}
// Build file paths
const uploadDir = join(process.cwd(), 'data', 'uploads', 'players', playerId)
const croppedFilepath = join(uploadDir, attachment.filename)
// Delete cropped file from disk (ignore error if file doesn't exist)
try {
await unlink(croppedFilepath)
} catch (err) {
// Log but don't fail if file is already gone
console.warn(`Could not delete cropped file: ${croppedFilepath}`, err)
}
// Also delete original file if it exists
if (attachment.originalFilename) {
const originalFilepath = join(uploadDir, attachment.originalFilename)
try {
await unlink(originalFilepath)
} catch (err) {
console.warn(`Could not delete original file: ${originalFilepath}`, err)
}
}
// Delete database record
await db.delete(practiceAttachments).where(eq(practiceAttachments.id, attachmentId))
return NextResponse.json({ success: true, deleted: attachmentId })
} catch (error) {
console.error('Error deleting attachment:', error)
return NextResponse.json({ error: 'Failed to delete attachment' }, { status: 500 })
}
}

View File

@@ -0,0 +1,60 @@
/**
* API route for player attachments
*
* GET /api/curriculum/[playerId]/attachments
*
* Returns attachment counts grouped by session ID for display in session history.
*/
import { NextResponse } from 'next/server'
import { eq, sql } from 'drizzle-orm'
import { db } from '@/db'
import { practiceAttachments } from '@/db/schema'
import { canPerformAction } from '@/lib/classroom'
import { getDbUserId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ playerId: string }>
}
/**
* GET - Get attachment counts per session
*/
export async function GET(_request: Request, { params }: RouteParams) {
try {
const { playerId } = await params
if (!playerId) {
return NextResponse.json({ error: 'Player ID required' }, { status: 400 })
}
// Authorization check
const userId = await getDbUserId()
const canView = await canPerformAction(userId, playerId, 'view')
if (!canView) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get attachment counts grouped by session
const counts = await db
.select({
sessionId: practiceAttachments.sessionId,
count: sql<number>`count(*)`.as('count'),
})
.from(practiceAttachments)
.where(eq(practiceAttachments.playerId, playerId))
.groupBy(practiceAttachments.sessionId)
.all()
// Transform to a map for easy lookup
const sessionCounts: Record<string, number> = {}
for (const row of counts) {
sessionCounts[row.sessionId] = row.count
}
return NextResponse.json({ sessionCounts })
} catch (error) {
console.error('Error fetching attachment counts:', error)
return NextResponse.json({ error: 'Failed to fetch attachment counts' }, { status: 500 })
}
}

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