Compare commits
145 Commits
abacus-rea
...
abacus-rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
990b573baa | ||
|
|
830a48e74f | ||
|
|
33efdf0c0d | ||
|
|
91aaddbeab | ||
|
|
5a4c751ebe | ||
|
|
9610ddb8f1 | ||
|
|
d80601d162 | ||
|
|
995cb60086 | ||
|
|
8a454158b5 | ||
|
|
41aa7ff33f | ||
|
|
1be6151bae | ||
|
|
70b363ce88 | ||
|
|
d90d263b2a | ||
|
|
43524d8238 | ||
|
|
a5025f01bc | ||
|
|
a8fb77e8e3 | ||
|
|
e80ef04f45 | ||
|
|
b3b769c0e2 | ||
|
|
ff59612e7b | ||
|
|
d8c764595d | ||
|
|
005140a1e7 | ||
|
|
5d0ac65bdd | ||
|
|
7a9185eadb | ||
|
|
da97ad0675 | ||
|
|
b206eb3071 | ||
|
|
8846cece93 | ||
|
|
937223e318 | ||
|
|
e52f94e4b4 | ||
|
|
957ff71cf1 | ||
|
|
44dcb01473 | ||
|
|
8e4975d395 | ||
|
|
9e9a06f2e4 | ||
|
|
dd8efe379d | ||
|
|
47088e4850 | ||
|
|
156a0dfe96 | ||
|
|
8be19958af | ||
|
|
d717f44fcc | ||
|
|
5f51bc1871 | ||
|
|
473b7dbd7c | ||
|
|
ff79a28c65 | ||
|
|
5f4f1fde33 | ||
|
|
9124a3182e | ||
|
|
2d3b319e57 | ||
|
|
518fe153c9 | ||
|
|
6d36da47b3 | ||
|
|
62aefad676 | ||
|
|
ce2cb331e8 | ||
|
|
2a24700e6c | ||
|
|
db17c96168 | ||
|
|
310672ceb9 | ||
|
|
f3bb0aee4f | ||
|
|
9b853116ec | ||
|
|
1656b9324f | ||
|
|
8727782e45 | ||
|
|
0e7f3265fe | ||
|
|
78a63e35e3 | ||
|
|
da92289ed1 | ||
|
|
dca696a29f | ||
|
|
a5e5788fa9 | ||
|
|
a8f55c9f4f | ||
|
|
474c4da05a | ||
|
|
a6584143eb | ||
|
|
4d6adf359e | ||
|
|
5fee1297e1 | ||
|
|
de39ab52cc | ||
|
|
ece319738b | ||
|
|
91d6d6a1b6 | ||
|
|
98a69f1f80 | ||
|
|
aab7469d9e | ||
|
|
3ac7b460ec | ||
|
|
8527f892e2 | ||
|
|
d06048bc2c | ||
|
|
01a606af4e | ||
|
|
ca1ed1980a | ||
|
|
cd259c9c58 | ||
|
|
1708899183 | ||
|
|
9f86077bef | ||
|
|
c0e63ff68b | ||
|
|
b31aba7aa3 | ||
|
|
c8f2984d7b | ||
|
|
6def610877 | ||
|
|
07484fdfac | ||
|
|
c631e10728 | ||
|
|
7b82995664 | ||
|
|
d6e369f9dc | ||
|
|
52df7f4697 | ||
|
|
2f1b9df9d9 | ||
|
|
bf262e7d53 | ||
|
|
fd1df93a8f | ||
|
|
d1176da9aa | ||
|
|
cb8b0dff67 | ||
|
|
0ba1551fea | ||
|
|
576abcb89e | ||
|
|
1b3dcbe14f | ||
|
|
9d8b5e1148 | ||
|
|
ccea0f86ac | ||
|
|
2f7002e575 | ||
|
|
962a52d756 | ||
|
|
4b7387905d | ||
|
|
fb73e85f2d | ||
|
|
2feb6844a4 | ||
|
|
9636f7f44a | ||
|
|
07f6bb7f9c | ||
|
|
2015494c0e | ||
|
|
a0693e9084 | ||
|
|
77336bea5b | ||
|
|
bbe0500fe9 | ||
|
|
629bfcfc03 | ||
|
|
1952a412ed | ||
|
|
398603c75a | ||
|
|
2202716f56 | ||
|
|
02842270c9 | ||
|
|
dfc2627ccb | ||
|
|
594e22c428 | ||
|
|
c9518a6b99 | ||
|
|
bf5b99afe9 | ||
|
|
1fc8949b06 | ||
|
|
c5a0586094 | ||
|
|
d5e4c858db | ||
|
|
c522620e46 | ||
|
|
446678799c | ||
|
|
b9d4bc552a | ||
|
|
7d03d8c69b | ||
|
|
f883fbfe23 | ||
|
|
b300ed9f5c | ||
|
|
f760ec130e | ||
|
|
d6c8e582a7 | ||
|
|
0e0356113d | ||
|
|
538718a814 | ||
|
|
11d48465d7 | ||
|
|
f804d24a29 | ||
|
|
35720820f3 | ||
|
|
430c46adb9 | ||
|
|
05aa87ffd2 | ||
|
|
2977bd57df | ||
|
|
4b291b304b | ||
|
|
aef5fadf86 | ||
|
|
d405038711 | ||
|
|
0c40c28a9a | ||
|
|
d128e808db | ||
|
|
9abf29d9fc | ||
|
|
2ffb71ab28 | ||
|
|
0e2fcee0ae | ||
|
|
1383db8185 | ||
|
|
6a98f6af95 |
@@ -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
3
.npmrc
Normal 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
|
||||
22
Dockerfile
22
Dockerfile
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
158
apps/web/.claude/PROBLEM_TO_REVIEW_REDESIGN.md
Normal file
158
apps/web/.claude/PROBLEM_TO_REVIEW_REDESIGN.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
1074
apps/web/.claude/SESSION_REPORT_REDESIGN_PLAN.md
Normal file
1074
apps/web/.claude/SESSION_REPORT_REDESIGN_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
335
apps/web/.claude/VISION_DOCK_INTEGRATION_PLAN.md
Normal file
335
apps/web/.claude/VISION_DOCK_INTEGRATION_PLAN.md
Normal 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
|
||||
@@ -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
9
apps/web/.gitignore
vendored
@@ -54,3 +54,12 @@ src/generated/build-info.json
|
||||
|
||||
# biome
|
||||
.biome
|
||||
|
||||
# Python virtual environments
|
||||
.venv*/
|
||||
|
||||
# User uploads
|
||||
data/uploads/
|
||||
|
||||
# ML training data
|
||||
training-data/
|
||||
|
||||
329
apps/web/__tests__/session-share.e2e.test.ts
Normal file
329
apps/web/__tests__/session-share.e2e.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
18
apps/web/drizzle/0038_drop_skill_stat_columns.sql
Normal file
18
apps/web/drizzle/0038_drop_skill_stat_columns.sql
Normal 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`;
|
||||
4
apps/web/drizzle/0039_add_player_archived.sql
Normal file
4
apps/web/drizzle/0039_add_player_archived.sql
Normal 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;
|
||||
@@ -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`;
|
||||
130
apps/web/drizzle/0041_classroom-system.sql
Normal file
130
apps/web/drizzle/0041_classroom-system.sql
Normal 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`;
|
||||
25
apps/web/drizzle/0042_classroom-system-indexes.sql
Normal file
25
apps/web/drizzle/0042_classroom-system-indexes.sql
Normal 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;
|
||||
6
apps/web/drizzle/0043_add_session_pause_columns.sql
Normal file
6
apps/web/drizzle/0043_add_session_pause_columns.sql
Normal 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;
|
||||
4
apps/web/drizzle/0044_add_is_paused_column.sql
Normal file
4
apps/web/drizzle/0044_add_is_paused_column.sql
Normal 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;
|
||||
21
apps/web/drizzle/0046_session_observation_shares.sql
Normal file
21
apps/web/drizzle/0046_session_observation_shares.sql
Normal 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`);
|
||||
22
apps/web/drizzle/0047_add_entry_prompts.sql
Normal file
22
apps/web/drizzle/0047_add_entry_prompts.sql
Normal 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';
|
||||
5
apps/web/drizzle/0048_ambitious_firedrake.sql
Normal file
5
apps/web/drizzle/0048_ambitious_firedrake.sql
Normal 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;
|
||||
2
apps/web/drizzle/0049_flowery_jean_grey.sql
Normal file
2
apps/web/drizzle/0049_flowery_jean_grey.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add retry_state column to session_plans for tracking retry epochs
|
||||
ALTER TABLE `session_plans` ADD `retry_state` text;
|
||||
21
apps/web/drizzle/0050_abandoned_salo.sql
Normal file
21
apps/web/drizzle/0050_abandoned_salo.sql
Normal 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`);
|
||||
4
apps/web/drizzle/0051_luxuriant_selene.sql
Normal file
4
apps/web/drizzle/0051_luxuriant_selene.sql
Normal 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;
|
||||
4
apps/web/drizzle/0052_remarkable_karnak.sql
Normal file
4
apps/web/drizzle/0052_remarkable_karnak.sql
Normal 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;
|
||||
3
apps/web/drizzle/0053_premium_expediter.sql
Normal file
3
apps/web/drizzle/0053_premium_expediter.sql
Normal 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;
|
||||
2
apps/web/drizzle/0054_new_mathemanic.sql
Normal file
2
apps/web/drizzle/0054_new_mathemanic.sql
Normal 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;
|
||||
37
apps/web/drizzle/0055_add_attachment_parsing.sql
Normal file
37
apps/web/drizzle/0055_add_attachment_parsing.sql
Normal 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;
|
||||
31
apps/web/drizzle/0056_add_llm_metadata.sql
Normal file
31
apps/web/drizzle/0056_add_llm_metadata.sql
Normal 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;
|
||||
3
apps/web/drizzle/0057_flowery_korath.sql
Normal file
3
apps/web/drizzle/0057_flowery_korath.sql
Normal 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;
|
||||
2
apps/web/drizzle/0058_blushing_impossible_man.sql
Normal file
2
apps/web/drizzle/0058_blushing_impossible_man.sql
Normal 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;
|
||||
1038
apps/web/drizzle/meta/0038_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0038_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0040_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0040_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0041_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0041_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0042_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0042_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0043_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0043_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0044_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0044_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0045_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0045_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0047_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0047_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0048_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0048_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0049_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0049_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0050_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0050_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0051_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0051_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0052_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0052_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0053_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0053_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0054_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0054_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0056_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0056_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0057_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0057_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0058_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0058_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
384
apps/web/e2e/api-authorization.spec.ts
Normal file
384
apps/web/e2e/api-authorization.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
567
apps/web/e2e/entry-prompts.spec.ts
Normal file
567
apps/web/e2e/entry-prompts.spec.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)' },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
674
apps/web/public/js-aruco2/aruco.js
Normal file
674
apps/web/public/js-aruco2/aruco.js
Normal 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
|
||||
}
|
||||
879
apps/web/public/js-aruco2/cv.js
Normal file
879
apps/web/public/js-aruco2/cv.js
Normal 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
|
||||
}
|
||||
Binary file not shown.
778
apps/web/public/models/abacus-column-classifier/model.json
Normal file
778
apps/web/public/models/abacus-column-classifier/model.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
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
129
apps/web/scripts/train-column-classifier/README.md
Normal file
129
apps/web/scripts/train-column-classifier/README.md
Normal 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
|
||||
217
apps/web/scripts/train-column-classifier/augmentation.ts
Normal file
217
apps/web/scripts/train-column-classifier/augmentation.ts
Normal 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
|
||||
}
|
||||
230
apps/web/scripts/train-column-classifier/generateTrainingData.ts
Normal file
230
apps/web/scripts/train-column-classifier/generateTrainingData.ts
Normal 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)
|
||||
})
|
||||
24
apps/web/scripts/train-column-classifier/index.ts
Normal file
24
apps/web/scripts/train-column-classifier/index.ts
Normal 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'
|
||||
75
apps/web/scripts/train-column-classifier/renderColumn.tsx
Normal file
75
apps/web/scripts/train-column-classifier/renderColumn.tsx
Normal 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),
|
||||
}
|
||||
}
|
||||
15
apps/web/scripts/train-column-classifier/requirements.txt
Normal file
15
apps/web/scripts/train-column-classifier/requirements.txt
Normal 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
|
||||
371
apps/web/scripts/train-column-classifier/train_model.py
Normal file
371
apps/web/scripts/train-column-classifier/train_model.py
Normal 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()
|
||||
144
apps/web/scripts/train-column-classifier/types.ts
Normal file
144
apps/web/scripts/train-column-classifier/types.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
117
apps/web/src/app/api/classrooms/[classroomId]/presence/route.ts
Normal file
117
apps/web/src/app/api/classrooms/[classroomId]/presence/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
134
apps/web/src/app/api/classrooms/[classroomId]/route.ts
Normal file
134
apps/web/src/app/api/classrooms/[classroomId]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
42
apps/web/src/app/api/classrooms/code/[code]/route.ts
Normal file
42
apps/web/src/app/api/classrooms/code/[code]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
45
apps/web/src/app/api/classrooms/mine/route.ts
Normal file
45
apps/web/src/app/api/classrooms/mine/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
77
apps/web/src/app/api/classrooms/route.ts
Normal file
77
apps/web/src/app/api/classrooms/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user