Compare commits
236 Commits
abacus-rea
...
abacus-rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
8f8af92286 | ||
|
|
3c9406afc5 | ||
|
|
0f84edec0a | ||
|
|
4daf7b7433 | ||
|
|
be08efe06f | ||
|
|
e9f9aaca16 | ||
|
|
7d8bb2f525 | ||
|
|
9851c01026 | ||
|
|
b345baf3c4 | ||
|
|
bb9506b93e | ||
|
|
56742c511d | ||
|
|
5735ff0810 | ||
|
|
ba68cfc75d | ||
|
|
129907fcc6 | ||
|
|
2702ec585f | ||
|
|
b94f5338e5 | ||
|
|
fad386f216 | ||
|
|
ceadd9de67 | ||
|
|
ff7554b005 | ||
|
|
c40baee43f | ||
|
|
b227162da6 | ||
|
|
6ef329dd60 | ||
|
|
df9f23d2a3 | ||
|
|
b0c0f5c2da | ||
|
|
ce85565f06 | ||
|
|
84217a8bb6 | ||
|
|
335c385390 | ||
|
|
6a4dd694a2 | ||
|
|
7085a4b3df | ||
|
|
354ada596d | ||
|
|
22cd11e2c3 | ||
|
|
86cd518c39 | ||
|
|
52a4a5cfda | ||
|
|
15b633f59a | ||
|
|
184cba0ec8 | ||
|
|
9c313d5303 | ||
|
|
bf4334b281 | ||
|
|
7a2390bd1b | ||
|
|
8851be5948 | ||
|
|
c0764ccd85 | ||
|
|
3c52e607b3 | ||
|
|
6c88dcfdc5 | ||
|
|
d8dee1d746 | ||
|
|
818fdb438d | ||
|
|
aae53aa426 | ||
|
|
147974a9f0 | ||
|
|
58192017c7 | ||
|
|
f0a9608a6b | ||
|
|
55e5c121f1 | ||
|
|
8802418fe5 | ||
|
|
85d36c80a2 | ||
|
|
a33e3e6d2b | ||
|
|
9f07bd6df6 | ||
|
|
49d3a8c2d6 | ||
|
|
b2e7268e7a | ||
|
|
59f574c178 | ||
|
|
2fca17a58b | ||
|
|
18ce1f41af | ||
|
|
0c40dd5c42 | ||
|
|
f45428ed82 | ||
|
|
cc5bb479c6 | ||
|
|
652519f219 | ||
|
|
4800a48128 | ||
|
|
8405f64486 | ||
|
|
c13feddfbb | ||
|
|
0ee14a71b6 | ||
|
|
80a33bcae2 | ||
|
|
366a1f4b83 | ||
|
|
dd3dd4507c | ||
|
|
883b683463 | ||
|
|
a892902e8a | ||
|
|
0d17809330 | ||
|
|
3f61dbc0b5 | ||
|
|
11ecb385ad | ||
|
|
826c8490ba | ||
|
|
9c1fd85ed5 | ||
|
|
60fc81bc2d | ||
|
|
2c832c7944 | ||
|
|
5fb4751728 | ||
|
|
1a7945dd0b | ||
|
|
5730bd6112 | ||
|
|
34d0232451 | ||
|
|
839171c0ff | ||
|
|
6c09976d4b | ||
|
|
31fbf80b8f | ||
|
|
1058f411c6 | ||
|
|
4b8cbdf83c | ||
|
|
ee8dccd83a | ||
|
|
1139c4d1a1 | ||
|
|
bcb1c7a173 | ||
|
|
a27fb0c9a4 | ||
|
|
f95456dadc | ||
|
|
5d61de4bf6 | ||
|
|
9159608dcd | ||
|
|
7cf689c3d9 | ||
|
|
5cfbeeb8df | ||
|
|
e5c697b7a8 | ||
|
|
4f7a9d76cd | ||
|
|
a3e79dac74 | ||
|
|
e42766c893 | ||
|
|
c40543ac64 | ||
|
|
245cc269fe | ||
|
|
c19109758a | ||
|
|
5ebc743b43 | ||
|
|
9c646acc16 | ||
|
|
f74db216da | ||
|
|
ae1a0a8e2d | ||
|
|
28b3b30da6 | ||
|
|
7b476e80c1 | ||
|
|
7243502873 | ||
|
|
8a9afa86bc | ||
|
|
43e7db4e88 | ||
|
|
ed277ef745 | ||
|
|
46ff5f528a | ||
|
|
36c9ec3301 | ||
|
|
1ce448eb0b | ||
|
|
4d41c9c54a |
@@ -181,9 +181,254 @@
|
||||
"Bash(shasum:*)",
|
||||
"Bash(open http://localhost:3000/arcade/matching)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(npm run type-check:*)"
|
||||
"Bash(npm run type-check:*)",
|
||||
"mcp__sqlite__read_query",
|
||||
"mcp__sqlite__list_tables",
|
||||
"mcp__sqlite__describe_table",
|
||||
"Bash(npm run format:*)",
|
||||
"Bash(npm run lint:fix:*)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(npx drizzle-kit:*)",
|
||||
"Bash(npm run db:migrate:*)",
|
||||
"Bash(npm run pre-commit:*)",
|
||||
"Bash(npm run seed:test-students:*)",
|
||||
"Bash(npx @biomejs/biome lint:*)",
|
||||
"Bash(npm run build:seed-script:*)",
|
||||
"Bash(ls:*)",
|
||||
"mcp__sqlite__write_query",
|
||||
"Bash(apps/web/src/lib/curriculum/session-mode.ts )",
|
||||
"Bash(apps/web/src/app/api/curriculum/[playerId]/session-mode/ )",
|
||||
"Bash(apps/web/src/hooks/useSessionMode.ts )",
|
||||
"Bash(apps/web/src/components/practice/SessionModeBanner.tsx )",
|
||||
"Bash(apps/web/src/components/practice/SessionModeBanner.stories.tsx )",
|
||||
"Bash(apps/web/src/components/practice/index.ts )",
|
||||
"Bash(apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx )",
|
||||
"Bash(apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx )",
|
||||
"Bash(apps/web/src/components/practice/StartPracticeModal.tsx )",
|
||||
"Bash(apps/web/src/components/practice/StartPracticeModal.stories.tsx)",
|
||||
"Bash(apps/web/src/lib/curriculum/session-planner.ts )",
|
||||
"Bash(apps/web/src/lib/curriculum/index.ts )",
|
||||
"Bash(apps/web/src/app/api/curriculum/[playerId]/sessions/plans/route.ts )",
|
||||
"Bash(apps/web/src/hooks/useSessionPlan.ts )",
|
||||
"Bash(apps/web/src/components/practice/StartPracticeModal.tsx)",
|
||||
"Bash(apps/web/.claude/REMEDIATION_CTA_PLAN.md)",
|
||||
"Bash(npx @biomejs/biome:*)",
|
||||
"Bash(apps/web/package.json )",
|
||||
"Bash(pnpm-lock.yaml )",
|
||||
"Bash(apps/web/src/components/practice/BannerSlots.tsx )",
|
||||
"Bash(apps/web/src/components/practice/BannerSlots.stories.tsx )",
|
||||
"Bash(apps/web/src/components/practice/ProjectingBanner.tsx )",
|
||||
"Bash(apps/web/src/components/practice/ProjectingBanner.stories.tsx )",
|
||||
"Bash(apps/web/src/components/practice/PracticeSubNav.tsx )",
|
||||
"Bash(apps/web/src/contexts/SessionModeBannerContext.tsx )",
|
||||
"Bash(apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx)",
|
||||
"Bash(\"apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx\" )",
|
||||
"Bash(apps/web/src/utils/__tests__/problemGenerator.budget.test.ts )",
|
||||
"Bash(apps/web/src/utils/__tests__/problemGenerator.stateAware.test.ts )",
|
||||
"Bash(apps/web/src/utils/__tests__/skillComplexity.test.ts )",
|
||||
"Bash(apps/web/src/lib/curriculum/progress-manager.ts )",
|
||||
"Bash(apps/web/src/lib/curriculum/config/complexity-budgets.ts )",
|
||||
"Bash(apps/web/src/lib/curriculum/config/skill-costs.ts )",
|
||||
"Bash(apps/web/src/lib/curriculum/config/bkt-integration.ts )",
|
||||
"Bash(apps/web/src/app/api/curriculum/[playerId]/skills/route.ts )",
|
||||
"Bash(apps/web/src/utils/skillComplexity.ts )",
|
||||
"Bash(apps/web/src/test/journey-simulator/comprehensive-ab-test.test.ts )",
|
||||
"Bash(apps/web/src/components/practice/TermSkillAnnotation.tsx )",
|
||||
"Bash(apps/web/src/components/practice/DetailedProblemCard.tsx )",
|
||||
"Bash(apps/web/src/db/schema/session-plans.ts)",
|
||||
"Bash(\"apps/web/src/app/api/curriculum/[playerId]/skills/route.ts\" )",
|
||||
"Bash(npm run test:*)",
|
||||
"Bash(\"apps/web/src/app/practice/[studentId]/placement-test/\" )",
|
||||
"Bash(\"apps/web/src/app/practice/[studentId]/skills/SkillsClient.tsx\" )",
|
||||
"Bash(\"apps/web/src/components/practice/ManualSkillSelector.tsx\" )",
|
||||
"Bash(\"apps/web/src/components/practice/OfflineSessionForm.tsx\" )",
|
||||
"Bash(\"apps/web/src/components/practice/OfflineSessionForm.stories.tsx\" )",
|
||||
"Bash(\"apps/web/src/components/practice/PlacementTest.tsx\" )",
|
||||
"Bash(\"apps/web/src/components/practice/PlacementTest.stories.tsx\" )",
|
||||
"Bash(\"apps/web/src/components/practice/PracticeSubNav.tsx\" )",
|
||||
"Bash(\"apps/web/src/components/practice/ProgressDashboard.tsx\" )",
|
||||
"Bash(\"apps/web/src/components/practice/ProgressDashboard.stories.tsx\" )",
|
||||
"Bash(\"apps/web/src/lib/curriculum/placement-test.ts\" )",
|
||||
"Bash(\"apps/web/src/test/journey-simulator/profiles/per-skill-deficiency.ts\")",
|
||||
"Bash(mcp__sqlite__read_query:*)",
|
||||
"Bash(mcp__sqlite__describe_table:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(npx tsx:*)",
|
||||
"Bash(xargs ls:*)",
|
||||
"Bash(mcp__sqlite__list_tables)",
|
||||
"WebFetch(domain:developer.chrome.com)",
|
||||
"Bash(claude mcp add:*)",
|
||||
"Bash(claude mcp:*)",
|
||||
"Bash(git rev-parse:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(src/lib/classroom/query-invalidations.ts )",
|
||||
"Bash(src/lib/classroom/socket-emitter.ts )",
|
||||
"Bash(src/lib/classroom/socket-events.ts )",
|
||||
"Bash(src/lib/queryKeys.ts )",
|
||||
"Bash(src/hooks/useClassroomSocket.ts )",
|
||||
"Bash(src/hooks/useParentSocket.ts )",
|
||||
"Bash(\"src/app/api/classrooms/[classroomId]/enrollment-requests/[requestId]/approve/route.ts\" )",
|
||||
"Bash(\"src/app/api/classrooms/[classroomId]/enrollment-requests/[requestId]/deny/route.ts\" )",
|
||||
"Bash(\"src/app/api/enrollment-requests/[requestId]/deny/route.ts\" )",
|
||||
"Bash(src/components/practice/NotesModal.tsx )",
|
||||
"Bash(src/components/classroom/EnterClassroomButton.tsx )",
|
||||
"Bash(src/components/classroom/index.ts )",
|
||||
"Bash(src/app/practice/PracticeClient.tsx)",
|
||||
"Bash(apps/web/src/app/api/curriculum/[playerId]/sessions/plans/[planId]/route.ts )",
|
||||
"Bash(apps/web/src/app/api/enrollment-requests/[requestId]/approve/route.ts )",
|
||||
"Bash(apps/web/src/components/classroom/EnterClassroomButton.tsx )",
|
||||
"Bash(apps/web/src/hooks/useClassroom.ts )",
|
||||
"Bash(apps/web/src/hooks/useClassroomSocket.ts )",
|
||||
"Bash(apps/web/src/hooks/usePlayerEnrollmentSocket.ts )",
|
||||
"Bash(apps/web/src/lib/classroom/query-invalidations.ts )",
|
||||
"Bash(apps/web/src/lib/classroom/socket-emitter.ts )",
|
||||
"Bash(apps/web/src/lib/classroom/socket-events.ts)",
|
||||
"Bash(apps/web/src/components/practice/SessionObserver.tsx )",
|
||||
"Bash(apps/web/src/components/practice/TeacherPausedOverlay.tsx )",
|
||||
"Bash(apps/web/drizzle/0043_add_session_pause_columns.sql )",
|
||||
"Bash(apps/web/drizzle/meta/0043_snapshot.json)",
|
||||
"Bash(apps/web/src/hooks/useSessionBroadcast.ts )",
|
||||
"Bash(apps/web/src/app/practice/[studentId]/PracticeClient.tsx )",
|
||||
"Bash(apps/web/src/components/classroom/ClassroomTab.tsx)",
|
||||
"Bash(src/app/practice/[studentId]/PracticeClient.tsx )",
|
||||
"Bash(src/components/classroom/ClassroomDashboard.tsx )",
|
||||
"Bash(src/components/classroom/ClassroomTab.tsx )",
|
||||
"Bash(src/components/classroom/SessionObserverModal.tsx )",
|
||||
"Bash(src/components/practice/ActiveSession.tsx )",
|
||||
"Bash(src/components/practice/index.ts )",
|
||||
"Bash(src/components/practice/PracticeFeedback.tsx )",
|
||||
"Bash(src/components/practice/PurposeBadge.tsx )",
|
||||
"Bash(src/components/ui/Tooltip.tsx )",
|
||||
"Bash(src/constants/zIndex.ts )",
|
||||
"Bash(src/hooks/useSessionBroadcast.ts )",
|
||||
"Bash(src/hooks/useSessionObserver.ts )",
|
||||
"Bash(src/socket-server.ts)",
|
||||
"Bash(src/components/MyAbacus.tsx )",
|
||||
"Bash(src/contexts/MyAbacusContext.tsx )",
|
||||
"Bash(src/components/practice/StartPracticeModal.tsx )",
|
||||
"Bash(src/components/tutorial/SkillTutorialLauncher.tsx )",
|
||||
"Bash(src/hooks/useSkillTutorialBroadcast.ts)",
|
||||
"Bash(\"src/app/practice/[studentId]/PracticeClient.tsx\" )",
|
||||
"Bash(apps/web/drizzle/meta/0044_snapshot.json )",
|
||||
"Bash(apps/web/drizzle/meta/_journal.json )",
|
||||
"Bash(apps/web/src/app/practice/PracticeClient.tsx )",
|
||||
"Bash(apps/web/src/components/classroom/EnrollChildModal.tsx )",
|
||||
"Bash(apps/web/src/components/classroom/index.ts )",
|
||||
"Bash(apps/web/src/components/family/FamilyCodeDisplay.tsx )",
|
||||
"Bash(apps/web/src/components/practice/NotesModal.tsx )",
|
||||
"Bash(apps/web/src/components/practice/StudentFilterBar.tsx )",
|
||||
"Bash(apps/web/src/components/practice/StudentSelector.tsx )",
|
||||
"Bash(apps/web/src/components/practice/StudentActionMenu.tsx )",
|
||||
"Bash(apps/web/src/components/practice/ViewSelector.tsx )",
|
||||
"Bash(apps/web/src/components/practice/studentActions.ts )",
|
||||
"Bash(apps/web/src/hooks/useStudentActions.ts )",
|
||||
"Bash(apps/web/src/hooks/useUnifiedStudents.ts )",
|
||||
"Bash(apps/web/src/types/student.ts)",
|
||||
"Bash(drizzle/meta/0044_snapshot.json )",
|
||||
"Bash(drizzle/meta/_journal.json )",
|
||||
"Bash(\"src/app/practice/[studentId]/dashboard/DashboardClient.tsx\" )",
|
||||
"Bash(src/components/classroom/EnrollChildModal.tsx )",
|
||||
"Bash(src/components/family/FamilyCodeDisplay.tsx )",
|
||||
"Bash(src/components/practice/StudentFilterBar.tsx )",
|
||||
"Bash(src/components/practice/StudentSelector.tsx )",
|
||||
"Bash(src/components/practice/StudentActionMenu.tsx )",
|
||||
"Bash(src/components/practice/ViewSelector.tsx )",
|
||||
"Bash(src/components/practice/studentActions.ts )",
|
||||
"Bash(src/hooks/useStudentActions.ts )",
|
||||
"Bash(src/hooks/useUnifiedStudents.ts )",
|
||||
"Bash(src/types/student.ts)",
|
||||
"Bash(ANALYZE=true pnpm next build:*)",
|
||||
"Bash(du:*)",
|
||||
"Bash(gzip:*)",
|
||||
"Bash({}/1024/1024\" | bc\\)MB\")",
|
||||
"Bash(114595/1024\" | bc\\) KB\" curl -s 'http://localhost:3000/_next/static/chunks/app/practice/page.js')",
|
||||
"Bash(done)",
|
||||
"Bash(PLAYWRIGHT_SKIP_BROWSER_GC=1 npx playwright test:*)",
|
||||
"Bash(BASE_URL=http://localhost:3000 npx playwright test:*)",
|
||||
"Bash(BASE_URL=http://localhost:3000 pnpm --filter @soroban/web exec playwright test:*)",
|
||||
"Bash(BASE_URL=http://localhost:3000 pnpm exec playwright test:*)",
|
||||
"Bash(git rebase:*)",
|
||||
"Bash(GIT_EDITOR=true git rebase:*)",
|
||||
"Bash(npm run test:run:*)",
|
||||
"Bash(git mv:*)",
|
||||
"Bash(drizzle/0050_abandoned_salo.sql )",
|
||||
"Bash(drizzle/meta/0050_snapshot.json )",
|
||||
"Bash(src/db/schema/practice-attachments.ts )",
|
||||
"Bash(src/db/schema/index.ts )",
|
||||
"Bash(\"src/app/api/curriculum/[playerId]/attachments/\" )",
|
||||
"Bash(\"src/app/api/curriculum/[playerId]/offline-sessions/\" )",
|
||||
"Bash(\"src/app/api/curriculum/[playerId]/sessions/[sessionId]/\" )",
|
||||
"Bash(src/components/practice/PhotoUploadZone.tsx )",
|
||||
"Bash(src/components/practice/SessionPhotoGallery.tsx )",
|
||||
"Bash(src/components/practice/OfflineSessionModal.tsx )",
|
||||
"Bash(src/components/practice/VirtualizedSessionList.tsx )",
|
||||
"Bash(\"src/app/practice/[studentId]/summary/SummaryClient.tsx\" )",
|
||||
"Bash(git ls-tree:*)",
|
||||
"Bash(apps/web/drizzle/0051_luxuriant_selene.sql )",
|
||||
"Bash(apps/web/drizzle/0052_remarkable_karnak.sql )",
|
||||
"Bash(apps/web/drizzle/0053_premium_expediter.sql )",
|
||||
"Bash(apps/web/drizzle/meta/0051_snapshot.json )",
|
||||
"Bash(apps/web/drizzle/meta/0052_snapshot.json )",
|
||||
"Bash(apps/web/drizzle/meta/0053_snapshot.json )",
|
||||
"Bash(\"apps/web/src/app/api/curriculum/[playerId]/attachments/[attachmentId]/file/route.ts\" )",
|
||||
"Bash(\"apps/web/src/app/api/curriculum/[playerId]/attachments/[attachmentId]/route.ts\" )",
|
||||
"Bash(\"apps/web/src/app/api/curriculum/[playerId]/attachments/[attachmentId]/original/\" )",
|
||||
"Bash(\"apps/web/src/app/api/curriculum/[playerId]/sessions/[sessionId]/attachments/route.ts\" )",
|
||||
"Bash(apps/web/src/components/practice/DocumentAdjuster.tsx )",
|
||||
"Bash(apps/web/src/components/practice/OfflineWorkSection.tsx )",
|
||||
"Bash(apps/web/src/components/practice/PhotoViewerEditor.tsx )",
|
||||
"Bash(apps/web/src/components/practice/ScrollspyNav.tsx )",
|
||||
"Bash(apps/web/src/components/practice/useDocumentDetection.ts )",
|
||||
"Bash(apps/web/src/db/schema/practice-attachments.ts)",
|
||||
"Bash(drizzle/0051_luxuriant_selene.sql )",
|
||||
"Bash(drizzle/0052_remarkable_karnak.sql )",
|
||||
"Bash(drizzle/0053_premium_expediter.sql )",
|
||||
"Bash(drizzle/meta/0051_snapshot.json )",
|
||||
"Bash(drizzle/meta/0052_snapshot.json )",
|
||||
"Bash(drizzle/meta/0053_snapshot.json )",
|
||||
"Bash(\"src/app/api/curriculum/[playerId]/attachments/[attachmentId]/file/route.ts\" )",
|
||||
"Bash(\"src/app/api/curriculum/[playerId]/attachments/[attachmentId]/route.ts\" )",
|
||||
"Bash(\"src/app/api/curriculum/[playerId]/attachments/[attachmentId]/original/\" )",
|
||||
"Bash(\"src/app/api/curriculum/[playerId]/sessions/[sessionId]/attachments/route.ts\" )",
|
||||
"Bash(src/components/practice/DocumentAdjuster.tsx )",
|
||||
"Bash(src/components/practice/OfflineWorkSection.tsx )",
|
||||
"Bash(src/components/practice/PhotoViewerEditor.tsx )",
|
||||
"Bash(src/components/practice/ScrollspyNav.tsx )",
|
||||
"Bash(src/components/practice/useDocumentDetection.ts )",
|
||||
"Bash(src/db/schema/practice-attachments.ts)",
|
||||
"Bash(apps/web/src/components/vision/ )",
|
||||
"Bash(apps/web/src/hooks/useAbacusVision.ts )",
|
||||
"Bash(apps/web/src/hooks/useCameraCalibration.ts )",
|
||||
"Bash(apps/web/src/hooks/useDeskViewCamera.ts )",
|
||||
"Bash(apps/web/src/hooks/useFrameStability.ts )",
|
||||
"Bash(apps/web/src/lib/vision/ )",
|
||||
"Bash(apps/web/src/types/vision.ts)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"Bash(head:*)",
|
||||
"Bash(apps/web/public/js-aruco2/ )",
|
||||
"Bash(apps/web/src/app/create/vision-markers/ )",
|
||||
"Bash(apps/web/src/lib/vision/arucoDetection.ts )",
|
||||
"Bash(apps/web/src/components/vision/AbacusVisionBridge.tsx )",
|
||||
"Bash(pnpm-lock.yaml)",
|
||||
"Bash(apps/web/src/app/api/remote-camera/ )",
|
||||
"Bash(apps/web/src/app/remote-camera/ )",
|
||||
"Bash(apps/web/src/components/vision/RemoteCameraQRCode.tsx )",
|
||||
"Bash(apps/web/src/components/vision/RemoteCameraReceiver.tsx )",
|
||||
"Bash(apps/web/src/hooks/useRemoteCameraDesktop.ts )",
|
||||
"Bash(apps/web/src/hooks/useRemoteCameraPhone.ts )",
|
||||
"Bash(apps/web/src/hooks/useRemoteCameraSession.ts )",
|
||||
"Bash(apps/web/src/lib/remote-camera/ )",
|
||||
"Bash(apps/web/src/lib/vision/perspectiveTransform.ts )",
|
||||
"Bash(apps/web/src/socket-server.ts)",
|
||||
"Bash(apps/web/src/components/vision/CalibrationOverlay.tsx )",
|
||||
"Bash(apps/web/src/components/practice/ActiveSession.tsx )"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"sqlite"
|
||||
]
|
||||
}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -59,3 +59,4 @@ temp/
|
||||
.claude/settings.local.json
|
||||
*storybook.log
|
||||
storybook-static
|
||||
apps/web/data/sqlite.db.backup.*
|
||||
|
||||
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"]
|
||||
|
||||
@@ -858,6 +858,7 @@ React component library for rendering interactive and static abacus visualizatio
|
||||
Interactive mathematical decomposition visualization showing step-by-step soroban operations. Features hoverable terms with pedagogical explanations, grouped operations, and bidirectional abacus coordination.
|
||||
|
||||
**Key Features**:
|
||||
|
||||
- **Interactive Terms** - Hover to see why each operation is performed
|
||||
- **Pedagogical Grouping** - Related operations (e.g., "+10 -3" for adding 7) grouped visually
|
||||
- **Step Tracking** - Integrates with tutorial and practice step progression
|
||||
@@ -871,6 +872,7 @@ Interactive mathematical decomposition visualization showing step-by-step soroba
|
||||
Structured curriculum-based practice system following traditional Japanese soroban teaching methodology.
|
||||
|
||||
**Key Features**:
|
||||
|
||||
- **Student Progress Tracking** - Per-skill mastery levels (learning → practicing → mastered)
|
||||
- **Session Planning** - Adaptive problem selection based on student history
|
||||
- **Teacher Controls** - Real-time session health monitoring and mid-session adjustments
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
# 3D Printing Docker Setup
|
||||
|
||||
## Summary
|
||||
|
||||
The 3D printable abacus customization feature is fully containerized with optimized Docker multi-stage builds.
|
||||
|
||||
**Key Technologies:**
|
||||
|
||||
- OpenSCAD 2021.01 (for rendering STL/3MF from .scad files)
|
||||
- BOSL2 v2.0.0 (minimized library, .scad files only)
|
||||
- Typst v0.11.1 (pre-built binary)
|
||||
|
||||
**Image Size:** ~257MB (optimized with multi-stage builds, saved ~38MB)
|
||||
|
||||
**Build Stages:** 7 total (base → builder → deps → typst-builder → bosl2-builder → runner)
|
||||
|
||||
## Overview
|
||||
|
||||
The 3D printable abacus customization feature requires OpenSCAD and the BOSL2 library to be available in the Docker container.
|
||||
|
||||
## Size Optimization Strategy
|
||||
|
||||
The Dockerfile uses **multi-stage builds** to minimize the final image size:
|
||||
|
||||
1. **typst-builder stage** - Downloads and extracts typst, discards wget/xz-utils
|
||||
2. **bosl2-builder stage** - Clones BOSL2 and removes unnecessary files (tests, docs, examples, images)
|
||||
3. **runner stage** - Only copies final binaries and minimized libraries
|
||||
|
||||
### Size Reductions
|
||||
|
||||
- **Removed from runner**: git, wget, curl, xz-utils (~40MB)
|
||||
- **BOSL2 minimized**: Removed .git, tests, tutorials, examples, images, markdown files (~2-3MB savings)
|
||||
- **Kept only .scad files** in BOSL2 library
|
||||
|
||||
## Dockerfile Changes
|
||||
|
||||
### Build Stages Overview
|
||||
|
||||
The Dockerfile now has **7 stages**:
|
||||
|
||||
1. **base** (Alpine) - Install build tools and dependencies
|
||||
2. **builder** (Alpine) - Build Next.js application
|
||||
3. **deps** (Alpine) - Install production node_modules
|
||||
4. **typst-builder** (Debian) - Download and extract typst binary
|
||||
5. **bosl2-builder** (Debian) - Clone and minimize BOSL2 library
|
||||
6. **runner** (Debian) - Final production image
|
||||
|
||||
### Stage 1-3: Base, Builder, Deps (unchanged)
|
||||
|
||||
Uses Alpine Linux for building the application (smaller and faster builds).
|
||||
|
||||
### Stage 4: Typst Builder (lines 68-87)
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-slim AS typst-builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
xz-utils \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN ARCH=$(uname -m) && \
|
||||
... download and install typst from GitHub releases
|
||||
```
|
||||
|
||||
**Purpose:** Download typst binary in isolation, then discard build tools (wget, xz-utils).
|
||||
|
||||
**Result:** Only the typst binary is copied to runner stage (line 120).
|
||||
|
||||
### Stage 5: BOSL2 Builder (lines 90-103)
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-slim AS bosl2-builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /bosl2 && \
|
||||
cd /bosl2 && \
|
||||
git clone --depth 1 --branch v2.0.0 https://github.com/BelfrySCAD/BOSL2.git . && \
|
||||
# Remove unnecessary files to minimize size
|
||||
rm -rf .git .github tests tutorials examples images *.md CONTRIBUTING* LICENSE* && \
|
||||
# Keep only .scad files and essential directories
|
||||
find . -type f ! -name "*.scad" -delete && \
|
||||
find . -type d -empty -delete
|
||||
```
|
||||
|
||||
**Purpose:** Clone BOSL2 and aggressively minimize by removing:
|
||||
|
||||
- `.git` directory
|
||||
- Tests, tutorials, examples
|
||||
- Documentation (markdown files)
|
||||
- Images
|
||||
- All non-.scad files
|
||||
|
||||
**Result:** Minimized BOSL2 library (~1-2MB instead of ~5MB) copied to runner (line 124).
|
||||
|
||||
### Stage 6: Runner - Production Image (lines 106-177)
|
||||
|
||||
**Base Image:** `node:18-slim` (Debian) - Required for OpenSCAD availability
|
||||
|
||||
**Runtime Dependencies (lines 111-117):**
|
||||
|
||||
```dockerfile
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
python3-pip \
|
||||
qpdf \
|
||||
openscad \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
**Removed from runner:**
|
||||
|
||||
- ❌ git (only needed in bosl2-builder)
|
||||
- ❌ wget (only needed in typst-builder)
|
||||
- ❌ curl (not needed at runtime)
|
||||
- ❌ xz-utils (only needed in typst-builder)
|
||||
|
||||
**Artifacts Copied from Other Stages:**
|
||||
|
||||
```dockerfile
|
||||
# From typst-builder (line 120)
|
||||
COPY --from=typst-builder /usr/local/bin/typst /usr/local/bin/typst
|
||||
|
||||
# From bosl2-builder (line 124)
|
||||
COPY --from=bosl2-builder /bosl2 /usr/share/openscad/libraries/BOSL2
|
||||
|
||||
# From builder (lines 131-159)
|
||||
# Next.js app, styled-system, server files, etc.
|
||||
|
||||
# From deps (lines 145-146)
|
||||
# Production node_modules only
|
||||
```
|
||||
|
||||
BOSL2 v2.0.0 (minimized) is copied to `/usr/share/openscad/libraries/BOSL2/`, which is OpenSCAD's default library search path. This allows `include <BOSL2/std.scad>` to work in the abacus.scad file.
|
||||
|
||||
### Temp Directory for Job Outputs (line 168)
|
||||
|
||||
```dockerfile
|
||||
RUN mkdir -p tmp/3d-jobs && chown nextjs:nodejs tmp
|
||||
```
|
||||
|
||||
Creates the directory where JobManager stores generated 3D files.
|
||||
|
||||
## Files Included in Docker Image
|
||||
|
||||
The following files are automatically included via the `COPY` command at line 132:
|
||||
|
||||
```
|
||||
apps/web/public/3d-models/
|
||||
├── abacus.scad (parametric OpenSCAD source)
|
||||
└── simplified.abacus.stl (base model, 4.8MB)
|
||||
```
|
||||
|
||||
These files are NOT excluded by `.dockerignore`.
|
||||
|
||||
## Testing the Docker Build
|
||||
|
||||
### Local Testing
|
||||
|
||||
1. **Build the Docker image:**
|
||||
|
||||
```bash
|
||||
docker build -t soroban-abacus-test .
|
||||
```
|
||||
|
||||
2. **Run the container:**
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 soroban-abacus-test
|
||||
```
|
||||
|
||||
3. **Test OpenSCAD inside the container:**
|
||||
|
||||
```bash
|
||||
docker exec -it <container-id> sh
|
||||
openscad --version
|
||||
ls /usr/share/openscad/libraries/BOSL2
|
||||
```
|
||||
|
||||
4. **Test the 3D printing endpoint:**
|
||||
- Visit http://localhost:3000/3d-print
|
||||
- Adjust parameters and generate a file
|
||||
- Monitor job progress
|
||||
- Download the result
|
||||
|
||||
### Verify BOSL2 Installation
|
||||
|
||||
Inside the running container:
|
||||
|
||||
```bash
|
||||
# Check OpenSCAD version
|
||||
openscad --version
|
||||
|
||||
# Verify BOSL2 library exists
|
||||
ls -la /usr/share/openscad/libraries/BOSL2/
|
||||
|
||||
# Test rendering a simple file
|
||||
cd /app/apps/web/public/3d-models
|
||||
openscad -o /tmp/test.stl abacus.scad
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Environment Variables
|
||||
|
||||
No additional environment variables are required for the 3D printing feature.
|
||||
|
||||
### Volume Mounts (Optional)
|
||||
|
||||
For better performance and to avoid rebuilding the image when updating 3D models:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 \
|
||||
-v $(pwd)/apps/web/public/3d-models:/app/apps/web/public/3d-models:ro \
|
||||
soroban-abacus-test
|
||||
```
|
||||
|
||||
### Disk Space Considerations
|
||||
|
||||
- **BOSL2 library**: ~5MB (cloned during build)
|
||||
- **Base STL file**: 4.8MB (in public/3d-models/)
|
||||
- **Generated files**: Vary by parameters, typically 1-10MB each
|
||||
- **Job cleanup**: Old jobs are automatically cleaned up after 1 hour
|
||||
|
||||
## Image Size
|
||||
|
||||
The final image is Debian-based (required for OpenSCAD), but optimized using multi-stage builds:
|
||||
|
||||
**Before optimization (original Debian approach):**
|
||||
|
||||
- Base runner: ~250MB
|
||||
- With all build tools (git, wget, curl, xz-utils): ~290MB
|
||||
- With BOSL2 (full): ~295MB
|
||||
- **Total: ~295MB**
|
||||
|
||||
**After optimization (current multi-stage approach):**
|
||||
|
||||
- Base runner: ~250MB
|
||||
- Runtime deps only (no build tools): ~250MB
|
||||
- BOSL2 (minimized, .scad only): ~252MB
|
||||
- 3D models (STL): ~257MB
|
||||
- **Total: ~257MB**
|
||||
|
||||
**Savings: ~38MB (~13% reduction)**
|
||||
|
||||
### What Was Removed
|
||||
|
||||
- ❌ git (~15MB)
|
||||
- ❌ wget (~2MB)
|
||||
- ❌ curl (~5MB)
|
||||
- ❌ xz-utils (~1MB)
|
||||
- ❌ BOSL2 .git directory (~1MB)
|
||||
- ❌ BOSL2 tests, examples, tutorials (~10MB)
|
||||
- ❌ BOSL2 images and docs (~4MB)
|
||||
|
||||
**Total removed: ~38MB**
|
||||
|
||||
This trade-off (Debian vs Alpine) is necessary for OpenSCAD availability, but the multi-stage approach minimizes the size impact.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### OpenSCAD Not Found
|
||||
|
||||
If you see "openscad: command not found" in logs:
|
||||
|
||||
1. Verify OpenSCAD is installed:
|
||||
|
||||
```bash
|
||||
docker exec -it <container-id> which openscad
|
||||
docker exec -it <container-id> openscad --version
|
||||
```
|
||||
|
||||
2. Check if the Debian package install succeeded:
|
||||
```bash
|
||||
docker exec -it <container-id> dpkg -l | grep openscad
|
||||
```
|
||||
|
||||
### BOSL2 Include Error
|
||||
|
||||
If OpenSCAD reports "Can't open library 'BOSL2/std.scad'":
|
||||
|
||||
1. Check BOSL2 exists:
|
||||
|
||||
```bash
|
||||
docker exec -it <container-id> ls /usr/share/openscad/libraries/BOSL2/std.scad
|
||||
```
|
||||
|
||||
2. Test include path:
|
||||
```bash
|
||||
docker exec -it <container-id> sh -c "cd /tmp && echo 'include <BOSL2/std.scad>; cube(10);' > test.scad && openscad -o test.stl test.scad"
|
||||
```
|
||||
|
||||
### Job Fails with "Permission Denied"
|
||||
|
||||
Check tmp directory permissions:
|
||||
|
||||
```bash
|
||||
docker exec -it <container-id> ls -la /app/apps/web/tmp
|
||||
# Should show: drwxr-xr-x ... nextjs nodejs ... 3d-jobs
|
||||
```
|
||||
|
||||
### Large File Generation Timeout
|
||||
|
||||
Jobs timeout after 60 seconds. For complex models, increase the timeout in `jobManager.ts:138`:
|
||||
|
||||
```typescript
|
||||
timeout: 120000, // 2 minutes instead of 60 seconds
|
||||
```
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- **Cold start**: First generation takes ~5-10 seconds (OpenSCAD initialization)
|
||||
- **Warm generations**: Subsequent generations take ~3-5 seconds
|
||||
- **STL size**: Typically 5-15MB depending on scale parameters
|
||||
- **3MF size**: Similar to STL (no significant compression)
|
||||
- **SCAD size**: ~1KB (just text parameters)
|
||||
|
||||
## Monitoring
|
||||
|
||||
Job processing is logged to stdout:
|
||||
|
||||
```
|
||||
Executing: openscad -o /app/apps/web/tmp/3d-jobs/abacus-abc123.stl ...
|
||||
Job abc123 completed successfully
|
||||
```
|
||||
|
||||
Check logs with:
|
||||
|
||||
```bash
|
||||
docker logs <container-id> | grep "Job"
|
||||
```
|
||||
@@ -7,10 +7,12 @@ When animating continuous rotation where the **speed changes smoothly** but you
|
||||
### The Problem
|
||||
|
||||
**CSS Animation approach fails because:**
|
||||
|
||||
- Changing `animation-duration` resets the animation phase, causing jumps
|
||||
- `animation-delay` tricks don't reliably preserve position across speed changes
|
||||
|
||||
**Calling `spring.start()` 60fps fails because:**
|
||||
|
||||
- React-spring's internal batching can't keep up with 60fps updates
|
||||
- Spring value lags 1000+ degrees behind, causing wild spinning
|
||||
- React re-renders interfere with spring updates
|
||||
|
||||
796
apps/web/.claude/BKT_DESIGN_SPEC.md
Normal file
796
apps/web/.claude/BKT_DESIGN_SPEC.md
Normal file
@@ -0,0 +1,796 @@
|
||||
# Bayesian Knowledge Tracing (BKT) Design Specification
|
||||
|
||||
## Overview
|
||||
|
||||
This document specifies the implementation of Conjunctive Bayesian Knowledge Tracing for the soroban practice system. BKT provides epistemologically honest skill mastery estimates that account for:
|
||||
|
||||
1. **Asymmetric evidence**: Correct answers prove all skills; wrong answers only prove ≥1 skill failed
|
||||
2. **Multi-skill problems**: Probabilistic blame distribution across co-occurring skills
|
||||
3. **Uncertainty quantification**: Confidence intervals on mastery estimates
|
||||
4. **Staleness indicators**: Show "last practiced X days ago" separately (not decay)
|
||||
|
||||
## Architecture Decision: Lazy Computation
|
||||
|
||||
**Key Decision**: BKT is computed on-demand when viewing reports, NOT in real-time during practice.
|
||||
|
||||
**Why:**
|
||||
|
||||
- No new database tables needed
|
||||
- No hooks into practice session flow
|
||||
- Can replay SlotResult history to compute BKT state
|
||||
- Easy to change algorithm without migration
|
||||
- Can add user controls (confidence slider, priors toggle) dynamically
|
||||
- Estimated computation time: ~50ms for full report
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. User opens Skills Dashboard
|
||||
2. Dashboard fetches recent SlotResults (already stored in session_plans)
|
||||
3. Pure functions replay history to compute BKT state for each skill
|
||||
4. Display results with confidence indicators
|
||||
|
||||
---
|
||||
|
||||
## The Problem We're Solving
|
||||
|
||||
**Current approach (naive):**
|
||||
|
||||
```
|
||||
accuracy = correct / attempts // Treats both signals as equivalent
|
||||
```
|
||||
|
||||
**Why it's wrong:**
|
||||
|
||||
- Correct: Strong evidence ALL skills are known
|
||||
- Incorrect: Weak evidence that ONE OR MORE skills failed (we don't know which)
|
||||
|
||||
**BKT approach:**
|
||||
|
||||
- Maintain P(known) per skill with proper Bayesian updates
|
||||
- Distribute "blame" for errors probabilistically based on prior beliefs
|
||||
- Report uncertainty honestly
|
||||
|
||||
---
|
||||
|
||||
## 1. Data Source
|
||||
|
||||
### Existing Data (No Schema Changes Needed)
|
||||
|
||||
We already have all the data we need in `session_plans.results`:
|
||||
|
||||
```typescript
|
||||
// From src/db/schema/session-plans.ts
|
||||
export interface SlotResult {
|
||||
slotIndex: number;
|
||||
problemIndex: number;
|
||||
problem: GeneratedProblem; // Contains skillIds
|
||||
isCorrect: boolean;
|
||||
timestamp: number;
|
||||
responseTimeMs: number;
|
||||
userAnswer: number | null;
|
||||
hadHelp: boolean; // Whether student used help during this problem
|
||||
}
|
||||
```
|
||||
|
||||
The `problem.skillIds` field tells us which skills were involved in each problem.
|
||||
|
||||
### Data Fetching
|
||||
|
||||
Already implemented: `getRecentSessionResults(playerId, sessionCount)` in `session-planner.ts`
|
||||
|
||||
---
|
||||
|
||||
## 2. BKT Algorithm (Pure Functions)
|
||||
|
||||
### 2.1 Core BKT Update Equations
|
||||
|
||||
```typescript
|
||||
// src/lib/curriculum/bkt/bkt-core.ts
|
||||
|
||||
export interface BktParams {
|
||||
pInit: number; // P(L0) - prior knowledge
|
||||
pLearn: number; // P(T) - learning rate
|
||||
pSlip: number; // P(S) - slip rate
|
||||
pGuess: number; // P(G) - guess rate
|
||||
}
|
||||
|
||||
export interface BktState {
|
||||
pKnown: number;
|
||||
opportunities: number;
|
||||
successCount: number;
|
||||
lastPracticedAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard BKT update for a SINGLE skill given an observation.
|
||||
*
|
||||
* For correct answer:
|
||||
* P(known | correct) = P(correct | known) × P(known) / P(correct)
|
||||
* where P(correct | known) = 1 - P(slip)
|
||||
* and P(correct | ¬known) = P(guess)
|
||||
*
|
||||
* For incorrect answer:
|
||||
* P(known | incorrect) = P(incorrect | known) × P(known) / P(incorrect)
|
||||
* where P(incorrect | known) = P(slip)
|
||||
* and P(incorrect | ¬known) = 1 - P(guess)
|
||||
*/
|
||||
export function bktUpdate(
|
||||
priorPKnown: number,
|
||||
isCorrect: boolean,
|
||||
params: BktParams,
|
||||
): number {
|
||||
const { pSlip, pGuess } = params;
|
||||
|
||||
if (isCorrect) {
|
||||
const pCorrect = priorPKnown * (1 - pSlip) + (1 - priorPKnown) * pGuess;
|
||||
const pKnownGivenCorrect = (priorPKnown * (1 - pSlip)) / pCorrect;
|
||||
return pKnownGivenCorrect;
|
||||
} else {
|
||||
const pIncorrect = priorPKnown * pSlip + (1 - priorPKnown) * (1 - pGuess);
|
||||
const pKnownGivenIncorrect = (priorPKnown * pSlip) / pIncorrect;
|
||||
return pKnownGivenIncorrect;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply learning transition after observation.
|
||||
* P(known after learning) = P(known) + P(¬known) × P(learn)
|
||||
*/
|
||||
export function applyLearning(pKnown: number, pLearn: number): number {
|
||||
return pKnown + (1 - pKnown) * pLearn;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Conjunctive BKT for Multi-Skill Problems
|
||||
|
||||
```typescript
|
||||
// src/lib/curriculum/bkt/conjunctive-bkt.ts
|
||||
|
||||
export interface SkillBktRecord {
|
||||
skillId: string;
|
||||
pKnown: number;
|
||||
params: BktParams;
|
||||
}
|
||||
|
||||
export interface BlameDistribution {
|
||||
skillId: string;
|
||||
blameWeight: number; // Higher = more likely this skill caused the error
|
||||
updatedPKnown: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a CORRECT multi-skill answer:
|
||||
* All skills receive positive evidence (student knew all of them).
|
||||
* Update each skill independently with the correct observation.
|
||||
*/
|
||||
export function updateOnCorrect(
|
||||
skills: SkillBktRecord[],
|
||||
): { skillId: string; updatedPKnown: number }[] {
|
||||
return skills.map((skill) => ({
|
||||
skillId: skill.skillId,
|
||||
updatedPKnown: applyLearning(
|
||||
bktUpdate(skill.pKnown, true, skill.params),
|
||||
skill.params.pLearn,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* For an INCORRECT multi-skill answer:
|
||||
* Distribute blame probabilistically based on which skill most likely failed.
|
||||
*
|
||||
* Simplified approximation:
|
||||
* blame(X) ∝ (1 - pKnown(X)) / Σ(1 - pKnown(all))
|
||||
*/
|
||||
export function updateOnIncorrect(
|
||||
skills: SkillBktRecord[],
|
||||
): BlameDistribution[] {
|
||||
const totalUnknown = skills.reduce((sum, s) => sum + (1 - s.pKnown), 0);
|
||||
|
||||
if (totalUnknown < 0.001) {
|
||||
// All skills appear mastered - must be a slip, distribute evenly
|
||||
const evenWeight = 1 / skills.length;
|
||||
return skills.map((skill) => ({
|
||||
skillId: skill.skillId,
|
||||
blameWeight: evenWeight,
|
||||
updatedPKnown: bktUpdate(skill.pKnown, false, skill.params),
|
||||
}));
|
||||
}
|
||||
|
||||
return skills.map((skill) => {
|
||||
const blameWeight = (1 - skill.pKnown) / totalUnknown;
|
||||
|
||||
// Weighted update: soften negative evidence for skills unlikely to have caused error
|
||||
const fullNegativeUpdate = bktUpdate(skill.pKnown, false, skill.params);
|
||||
const weightedPKnown =
|
||||
skill.pKnown * (1 - blameWeight) + fullNegativeUpdate * blameWeight;
|
||||
|
||||
return {
|
||||
skillId: skill.skillId,
|
||||
blameWeight,
|
||||
updatedPKnown: weightedPKnown,
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Evidence Quality Modifiers
|
||||
|
||||
```typescript
|
||||
// src/lib/curriculum/bkt/evidence-quality.ts
|
||||
|
||||
/**
|
||||
* Adjust observation weight based on whether help was used.
|
||||
* Using help = less confident the student really knows it.
|
||||
*
|
||||
* Note: Help is a boolean (hadHelp: true = used help, false = no help).
|
||||
* We can't determine which skill needed help for multi-skill problems,
|
||||
* so we apply the discount uniformly and let conjunctive BKT identify
|
||||
* weak skills from aggregated evidence.
|
||||
*/
|
||||
export function helpWeight(hadHelp: boolean): number {
|
||||
return hadHelp ? 0.5 : 1.0; // 50% weight for helped answers
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust observation weight based on response time.
|
||||
*
|
||||
* - Fast correct → strong evidence of mastery
|
||||
* - Slow correct → might have struggled
|
||||
* - Fast incorrect → careless slip (less negative)
|
||||
* - Slow incorrect → genuine confusion (stronger negative)
|
||||
*/
|
||||
export function responseTimeWeight(
|
||||
responseTimeMs: number,
|
||||
isCorrect: boolean,
|
||||
expectedTimeMs: number = 5000,
|
||||
): number {
|
||||
const ratio = responseTimeMs / expectedTimeMs;
|
||||
|
||||
if (isCorrect) {
|
||||
if (ratio < 0.5) return 1.2; // Very fast - strong mastery
|
||||
if (ratio > 2.0) return 0.8; // Very slow - struggled
|
||||
return 1.0;
|
||||
} else {
|
||||
if (ratio < 0.3) return 0.5; // Very fast error - careless slip
|
||||
if (ratio > 2.0) return 1.2; // Very slow error - genuine confusion
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Domain-Informed Priors
|
||||
|
||||
```typescript
|
||||
// src/lib/curriculum/bkt/skill-priors.ts
|
||||
|
||||
export function getDefaultParams(skillId: string): BktParams {
|
||||
// Basic skills are easier to learn
|
||||
if (skillId.startsWith("basic.")) {
|
||||
return { pInit: 0.3, pLearn: 0.4, pSlip: 0.05, pGuess: 0.02 };
|
||||
}
|
||||
// Five complements are moderately difficult
|
||||
if (skillId.startsWith("fiveComplements")) {
|
||||
return { pInit: 0.1, pLearn: 0.3, pSlip: 0.1, pGuess: 0.02 };
|
||||
}
|
||||
// Ten complements are harder
|
||||
if (skillId.startsWith("tenComplements")) {
|
||||
return { pInit: 0.05, pLearn: 0.25, pSlip: 0.15, pGuess: 0.02 };
|
||||
}
|
||||
// Mixed complements are hardest
|
||||
if (skillId.startsWith("mixedComplements")) {
|
||||
return { pInit: 0.02, pLearn: 0.2, pSlip: 0.2, pGuess: 0.02 };
|
||||
}
|
||||
// Default
|
||||
return { pInit: 0.1, pLearn: 0.3, pSlip: 0.1, pGuess: 0.05 };
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 Confidence Calculation
|
||||
|
||||
```typescript
|
||||
// src/lib/curriculum/bkt/confidence.ts
|
||||
|
||||
/**
|
||||
* Calculate confidence in pKnown estimate.
|
||||
* Based on number of opportunities and consistency of observations.
|
||||
* Returns value in [0, 1] where 1 = highly confident.
|
||||
*/
|
||||
export function calculateConfidence(
|
||||
opportunities: number,
|
||||
successRate: number,
|
||||
): number {
|
||||
// More data = more confidence (asymptotic to 1)
|
||||
const dataConfidence = 1 - Math.exp(-opportunities / 20);
|
||||
|
||||
// Extreme success rates (very high or very low) = more confidence
|
||||
const extremity = Math.abs(successRate - 0.5) * 2; // 0 at 50%, 1 at 0% or 100%
|
||||
const consistencyBonus = extremity * 0.2;
|
||||
|
||||
return Math.min(1, dataConfidence + consistencyBonus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get confidence label for display.
|
||||
*/
|
||||
export function getConfidenceLabel(confidence: number): string {
|
||||
if (confidence > 0.7) return "confident";
|
||||
if (confidence > 0.4) return "moderate";
|
||||
return "uncertain";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate uncertainty range around pKnown estimate.
|
||||
* Wider range when confidence is low.
|
||||
*/
|
||||
export function getUncertaintyRange(
|
||||
pKnown: number,
|
||||
confidence: number,
|
||||
): { low: number; high: number } {
|
||||
const uncertainty = (1 - confidence) * 0.3; // Max ±30% when confidence = 0
|
||||
return {
|
||||
low: Math.max(0, pKnown - uncertainty),
|
||||
high: Math.min(1, pKnown + uncertainty),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Main BKT Computation Function
|
||||
|
||||
```typescript
|
||||
// src/lib/curriculum/bkt/compute-bkt.ts
|
||||
|
||||
import type { ProblemResultWithContext } from "../session-planner";
|
||||
import { getDefaultParams, type BktParams } from "./skill-priors";
|
||||
import { updateOnCorrect, updateOnIncorrect } from "./conjunctive-bkt";
|
||||
import { helpWeight, responseTimeWeight } from "./evidence-quality";
|
||||
import { calculateConfidence, getUncertaintyRange } from "./confidence";
|
||||
|
||||
export interface BktComputeOptions {
|
||||
/** Confidence threshold for mastery classification */
|
||||
confidenceThreshold: number;
|
||||
/** Use cross-student priors (aggregated from other students) */
|
||||
useCrossStudentPriors: boolean;
|
||||
}
|
||||
|
||||
export interface SkillBktResult {
|
||||
skillId: string;
|
||||
pKnown: number;
|
||||
confidence: number;
|
||||
uncertaintyRange: { low: number; high: number };
|
||||
opportunities: number;
|
||||
successCount: number;
|
||||
lastPracticedAt: Date | null;
|
||||
masteryClassification: "mastered" | "learning" | "struggling";
|
||||
}
|
||||
|
||||
export interface BktComputeResult {
|
||||
skills: SkillBktResult[];
|
||||
interventionNeeded: SkillBktResult[];
|
||||
strengths: SkillBktResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute BKT state for all skills from problem history.
|
||||
* This is the main entry point - call it when displaying the Skills Dashboard.
|
||||
*/
|
||||
export function computeBktFromHistory(
|
||||
results: ProblemResultWithContext[],
|
||||
options: BktComputeOptions = {
|
||||
confidenceThreshold: 0.5,
|
||||
useCrossStudentPriors: false,
|
||||
},
|
||||
): BktComputeResult {
|
||||
// Sort by timestamp to replay in order
|
||||
const sorted = [...results].sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
// Track state for each skill
|
||||
const skillStates = new Map<
|
||||
string,
|
||||
{
|
||||
pKnown: number;
|
||||
opportunities: number;
|
||||
successCount: number;
|
||||
lastPracticedAt: Date | null;
|
||||
params: BktParams;
|
||||
}
|
||||
>();
|
||||
|
||||
// Initialize and update for each problem
|
||||
for (const result of sorted) {
|
||||
const skillIds = result.problem.skillIds ?? [];
|
||||
if (skillIds.length === 0) continue;
|
||||
|
||||
// Ensure all skills have state
|
||||
for (const skillId of skillIds) {
|
||||
if (!skillStates.has(skillId)) {
|
||||
const params = getDefaultParams(skillId);
|
||||
skillStates.set(skillId, {
|
||||
pKnown: params.pInit,
|
||||
opportunities: 0,
|
||||
successCount: 0,
|
||||
lastPracticedAt: null,
|
||||
params,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build skill records for BKT update
|
||||
const skillRecords = skillIds.map((skillId) => {
|
||||
const state = skillStates.get(skillId)!;
|
||||
return {
|
||||
skillId,
|
||||
pKnown: state.pKnown,
|
||||
params: state.params,
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate evidence weight
|
||||
const helpW = helpWeight(result.hadHelp);
|
||||
const rtWeight = responseTimeWeight(
|
||||
result.responseTimeMs,
|
||||
result.isCorrect,
|
||||
);
|
||||
const evidenceWeight = helpW * rtWeight;
|
||||
|
||||
// Compute updates
|
||||
const updates = result.isCorrect
|
||||
? updateOnCorrect(skillRecords)
|
||||
: updateOnIncorrect(skillRecords);
|
||||
|
||||
// Apply updates with evidence weighting
|
||||
for (const update of updates) {
|
||||
const state = skillStates.get(update.skillId)!;
|
||||
|
||||
// Weighted blend between old and new pKnown based on evidence quality
|
||||
const newPKnown =
|
||||
state.pKnown * (1 - evidenceWeight) +
|
||||
update.updatedPKnown * evidenceWeight;
|
||||
|
||||
state.pKnown = newPKnown;
|
||||
state.opportunities += 1;
|
||||
if (result.isCorrect) state.successCount += 1;
|
||||
state.lastPracticedAt = new Date(result.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to results
|
||||
const skills: SkillBktResult[] = [];
|
||||
|
||||
for (const [skillId, state] of skillStates) {
|
||||
const successRate =
|
||||
state.opportunities > 0 ? state.successCount / state.opportunities : 0.5;
|
||||
const confidence = calculateConfidence(state.opportunities, successRate);
|
||||
const uncertaintyRange = getUncertaintyRange(state.pKnown, confidence);
|
||||
|
||||
// Classify mastery
|
||||
let masteryClassification: "mastered" | "learning" | "struggling";
|
||||
if (state.pKnown >= 0.8 && confidence >= options.confidenceThreshold) {
|
||||
masteryClassification = "mastered";
|
||||
} else if (
|
||||
state.pKnown < 0.5 &&
|
||||
confidence >= options.confidenceThreshold
|
||||
) {
|
||||
masteryClassification = "struggling";
|
||||
} else {
|
||||
masteryClassification = "learning";
|
||||
}
|
||||
|
||||
skills.push({
|
||||
skillId,
|
||||
pKnown: state.pKnown,
|
||||
confidence,
|
||||
uncertaintyRange,
|
||||
opportunities: state.opportunities,
|
||||
successCount: state.successCount,
|
||||
lastPracticedAt: state.lastPracticedAt,
|
||||
masteryClassification,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by pKnown ascending (struggling skills first)
|
||||
skills.sort((a, b) => a.pKnown - b.pKnown);
|
||||
|
||||
// Identify intervention needed (low pKnown with high confidence)
|
||||
const interventionNeeded = skills.filter(
|
||||
(s) => s.masteryClassification === "struggling",
|
||||
);
|
||||
|
||||
// Identify strengths (high pKnown with high confidence)
|
||||
const strengths = skills.filter(
|
||||
(s) => s.masteryClassification === "mastered",
|
||||
);
|
||||
|
||||
return { skills, interventionNeeded, strengths };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. UI Display Updates
|
||||
|
||||
### 4.1 Honest Language Guidelines
|
||||
|
||||
**DON'T say:**
|
||||
|
||||
- "85% accuracy" (misleading - implies binary success tracking)
|
||||
- "Mastery: 85%" (implies certainty we don't have)
|
||||
- "You know this skill" (we can't know for sure)
|
||||
|
||||
**DO say:**
|
||||
|
||||
- "~73% mastered (moderate confidence)"
|
||||
- "Estimated: 73% ± 15%"
|
||||
- "Appears mastered (based on 12 problems)"
|
||||
- "Needs attention (5 recent errors)"
|
||||
|
||||
### 4.2 Skill Card Display
|
||||
|
||||
```typescript
|
||||
interface SkillDisplayData {
|
||||
skillId: string;
|
||||
displayName: string;
|
||||
|
||||
// BKT metrics
|
||||
pKnown: number; // 0-1, the main estimate
|
||||
confidence: number; // 0-1, how certain we are
|
||||
uncertaintyRange: { low: number; high: number };
|
||||
|
||||
// Raw evidence
|
||||
opportunities: number; // Total problems
|
||||
successCount: number;
|
||||
errorCount: number; // opportunities - successCount
|
||||
|
||||
// Staleness
|
||||
lastPracticedAt: Date | null;
|
||||
daysSinceLastPractice: number | null;
|
||||
}
|
||||
|
||||
// Display:
|
||||
// "~73% mastered (moderate confidence)"
|
||||
// "Based on 15 problems (12 correct, 3 with errors)"
|
||||
// "Last practiced 3 days ago"
|
||||
```
|
||||
|
||||
### 4.3 Staleness Indicator
|
||||
|
||||
Show staleness separately from P(known) - don't apply decay to the estimate.
|
||||
|
||||
```typescript
|
||||
function getStalenessWarning(
|
||||
daysSinceLastPractice: number | null,
|
||||
): string | null {
|
||||
if (daysSinceLastPractice === null) return null;
|
||||
if (daysSinceLastPractice < 7) return null;
|
||||
if (daysSinceLastPractice < 14) return "Not practiced recently";
|
||||
if (daysSinceLastPractice < 30) return "Getting rusty";
|
||||
return "Very stale - may need review";
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 UI Controls
|
||||
|
||||
**Confidence Threshold Slider:**
|
||||
|
||||
- Default: 0.5
|
||||
- Range: 0.3 to 0.8
|
||||
- Affects mastery classification: higher threshold = stricter "mastered" label
|
||||
|
||||
**Cross-Student Priors Toggle (future):**
|
||||
|
||||
- Default: off (use domain-informed priors only)
|
||||
- When on: adjust priors based on aggregate student data
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation Plan
|
||||
|
||||
### Phase 1: Core BKT Functions (No DB Changes)
|
||||
|
||||
1. Create `src/lib/curriculum/bkt/` directory
|
||||
2. Implement pure functions: bkt-core.ts, conjunctive-bkt.ts, evidence-quality.ts, skill-priors.ts, confidence.ts
|
||||
3. Implement main entry point: compute-bkt.ts
|
||||
4. Write unit tests for BKT math
|
||||
|
||||
### Phase 2: Skills Dashboard Update
|
||||
|
||||
1. Update `SkillsClient.tsx` to call `computeBktFromHistory()`
|
||||
2. Replace naive accuracy display with P(known) + confidence
|
||||
3. Use honest language in all labels
|
||||
4. Add staleness indicators
|
||||
|
||||
### Phase 3: UI Controls
|
||||
|
||||
1. Add confidence threshold slider to Skills Dashboard
|
||||
2. Store preference in localStorage
|
||||
3. (Future) Add cross-student priors toggle
|
||||
|
||||
---
|
||||
|
||||
## 6. Open Questions (Deferred)
|
||||
|
||||
1. **Cross-student priors**: How do we aggregate data across students to inform priors?
|
||||
- Answer: Deferred. Start with domain-informed priors only.
|
||||
|
||||
2. **Decay vs Staleness**: Should we eventually add decay?
|
||||
- Answer: Show staleness indicator for now. Can add optional decay toggle later.
|
||||
|
||||
3. **Parameter estimation**: Should P(T), P(S), P(G) be learned from data?
|
||||
- Answer: Start with domain-informed values. Can tune later with A/B testing.
|
||||
|
||||
---
|
||||
|
||||
## 7. BKT-Driven Problem Generation
|
||||
|
||||
**Implemented in December 2024**
|
||||
|
||||
### 7.1 Problem Generation Modes
|
||||
|
||||
Students can choose between two modes in the "Ready to Practice" modal:
|
||||
|
||||
**Adaptive Mode (Default):**
|
||||
|
||||
- Uses BKT P(known) estimates for continuous complexity scaling
|
||||
- Formula: `multiplier = 4 - (pKnown × 3)`
|
||||
- Requires confidence ≥ 0.5 (~20 problems with skill)
|
||||
- Falls back to Classic mode if insufficient data
|
||||
|
||||
**Classic Mode:**
|
||||
|
||||
- Uses fluency-based discrete multipliers
|
||||
- `effortless (1×), fluent (2×), rusty (3×), practicing (3×), not_practicing (4×)`
|
||||
- Fluency requires: ≥5 consecutive correct, ≥10 attempts, ≥85% accuracy
|
||||
|
||||
### 7.2 Implementation Files
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------- | ---------------------------------------- |
|
||||
| `config/bkt-integration.ts` | BKT config and multiplier calculation |
|
||||
| `utils/skillComplexity.ts` | Cost calculator with BKT support |
|
||||
| `session-planner.ts` | Session planning with BKT loading |
|
||||
| `StartPracticeModal.tsx` | Mode selection UI |
|
||||
| `SkillsClient.tsx` | Skills dashboard with multiplier display |
|
||||
|
||||
### 7.3 User Preference Storage
|
||||
|
||||
```sql
|
||||
-- player_curriculum table
|
||||
problem_generation_mode TEXT DEFAULT 'adaptive' NOT NULL
|
||||
-- Values: 'adaptive' | 'classic'
|
||||
```
|
||||
|
||||
### 7.4 Skills Dashboard Consistency
|
||||
|
||||
The Skills Dashboard now shows:
|
||||
|
||||
1. **P(known) estimate** - Same BKT estimate used for problem generation
|
||||
2. **Complexity multiplier** - Actual multiplier that will be used (e.g., "1.75×")
|
||||
3. **Mode indicator** - Whether BKT or fluency is being used for this skill
|
||||
|
||||
This ensures complete transparency about what drives problem generation.
|
||||
|
||||
---
|
||||
|
||||
## 8. Recency Refresh (Sentinel Records)
|
||||
|
||||
**Implemented in December 2024**
|
||||
|
||||
### 8.1 The Problem: Abstraction Gap
|
||||
|
||||
Teachers need to mark skills as "recently practiced" when students do offline work
|
||||
(e.g., workbooks, tutoring sessions). This resets the staleness indicator without
|
||||
changing the BKT mastery estimate.
|
||||
|
||||
**Original (broken) approach:**
|
||||
|
||||
- Database field `player_skill_mastery.lastPracticedAt` for manual override
|
||||
- BKT computed `lastPracticedAt` from problem history
|
||||
- **Two separate sources** created an abstraction gap:
|
||||
- UI sometimes used database field (stale)
|
||||
- Chart used BKT computed value (correct)
|
||||
- Inconsistency caused confusion and bugs
|
||||
|
||||
### 8.2 The Sentinel Approach
|
||||
|
||||
**Single source of truth:** All `lastPracticedAt` values come from problem history.
|
||||
|
||||
When a teacher clicks "Mark Current" for a skill:
|
||||
|
||||
1. A **sentinel record** is inserted into session history
|
||||
2. The sentinel has `source: 'recency-refresh'`
|
||||
3. BKT naturally processes it and updates `lastPracticedAt`
|
||||
4. BKT skips the sentinel for P(known) calculation (zero-weight)
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- No abstraction gap - BKT is the single source of truth
|
||||
- No MAX logic to combine two data sources
|
||||
- Clear semantics - sentinels are explicitly marked
|
||||
- Natural integration - flows through existing query paths
|
||||
|
||||
### 8.3 Implementation Details
|
||||
|
||||
**SlotResult schema** (`session-plans.ts`):
|
||||
|
||||
```typescript
|
||||
export type SlotResultSource = "practice" | "recency-refresh";
|
||||
|
||||
export interface SlotResult {
|
||||
// ... other fields ...
|
||||
|
||||
/**
|
||||
* Source of this record. Defaults to 'practice' when undefined.
|
||||
*
|
||||
* 'recency-refresh' records are sentinels inserted when a teacher clicks
|
||||
* "Mark Current" to indicate offline practice. BKT uses these for
|
||||
* lastPracticedAt but skips them for pKnown calculation (zero-weight).
|
||||
*/
|
||||
source?: SlotResultSource;
|
||||
}
|
||||
```
|
||||
|
||||
**Session status** (`session-plans.ts`):
|
||||
|
||||
```typescript
|
||||
export type SessionStatus =
|
||||
| "draft"
|
||||
| "approved"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "abandoned"
|
||||
| "recency-refresh"; // Sessions containing only sentinel records
|
||||
```
|
||||
|
||||
**BKT handling** (`compute-bkt.ts`):
|
||||
|
||||
```typescript
|
||||
// Check if this is a recency-refresh sentinel record
|
||||
const isRecencyRefresh = result.source === "recency-refresh";
|
||||
|
||||
if (isRecencyRefresh) {
|
||||
// Only update lastPracticedAt - skip pKnown calculation
|
||||
for (const skillId of skillIds) {
|
||||
const state = skillStates.get(skillId)!;
|
||||
if (!state.lastPracticedAt || timestamp > state.lastPracticedAt) {
|
||||
state.lastPracticedAt = timestamp;
|
||||
}
|
||||
}
|
||||
continue; // Skip BKT updates for sentinel records
|
||||
}
|
||||
```
|
||||
|
||||
**Query inclusion** (`session-planner.ts`):
|
||||
|
||||
```typescript
|
||||
const sessions = await db.query.sessionPlans.findMany({
|
||||
where: and(
|
||||
eq(schema.sessionPlans.playerId, playerId),
|
||||
inArray(schema.sessionPlans.status, ["completed", "recency-refresh"]),
|
||||
),
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### 8.4 API Usage
|
||||
|
||||
**Mark skill as recently practiced:**
|
||||
|
||||
```
|
||||
PATCH /api/curriculum/[playerId]/skills
|
||||
Body: { skillId: string }
|
||||
|
||||
Returns: { sessionId: string, timestamp: Date }
|
||||
```
|
||||
|
||||
The endpoint inserts a recency-refresh sentinel session. The next time BKT is
|
||||
computed, the skill's `lastPracticedAt` will reflect the refresh timestamp,
|
||||
removing the staleness warning.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Corbett, A. T., & Anderson, J. R. (1994). Knowledge tracing: Modeling the acquisition of procedural knowledge.
|
||||
- Pardos, Z. A., & Heffernan, N. T. (2011). KT-IDEM: Introducing item difficulty to the knowledge tracing model.
|
||||
213
apps/web/.claude/BKT_PROBLEM_GENERATION_PLAN.md
Normal file
213
apps/web/.claude/BKT_PROBLEM_GENERATION_PLAN.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# BKT-Driven Problem Generation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** Use BKT P(known) estimates to drive problem complexity budgeting, replacing the discrete fluency-based system. Add preference toggle and ensure transparency across the system.
|
||||
|
||||
**Status:** Implementation in progress
|
||||
|
||||
---
|
||||
|
||||
## Current State vs Target State
|
||||
|
||||
| Aspect | Current (Fluency) | Target (BKT) |
|
||||
| --------------------- | ------------------------------- | ------------------------------------- |
|
||||
| **Output** | 5 discrete states | Continuous P(known) [0,1] |
|
||||
| **Multi-skill blame** | All skills get +1 attempt | Probabilistic: `blame ∝ (1 - pKnown)` |
|
||||
| **Help level** | Heavy help breaks streak | Weighted evidence: 1.0×, 0.8×, 0.5× |
|
||||
| **Response time** | Recorded but IGNORED | Weighted evidence: 0.5× to 1.2× |
|
||||
| **Confidence** | None | Built-in confidence measure |
|
||||
| **Progress** | Binary threshold (cliff effect) | Continuous smooth updates |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Flow
|
||||
|
||||
```
|
||||
generateSessionPlan()
|
||||
│
|
||||
├─ Load problem history → getRecentSessionResults(playerId, 50)
|
||||
│
|
||||
├─ Compute BKT → computeBktFromHistory(problemHistory)
|
||||
│ Returns: Map<skillId, {pKnown, confidence}>
|
||||
│
|
||||
└─ createSkillCostCalculator(fluencyHistory, { bktResults, useBktScaling })
|
||||
│
|
||||
├─ IF useBktScaling AND bkt[skillId].confidence ≥ 0.5:
|
||||
│ multiplier = 4 - (pKnown × 3) // Continuous [1, 4]
|
||||
│
|
||||
└─ ELSE: fluency fallback (discrete [1, 4])
|
||||
```
|
||||
|
||||
### Multiplier Mapping
|
||||
|
||||
**BKT Continuous:**
|
||||
|
||||
- `pKnown = 0.0` → multiplier 4.0 (struggling)
|
||||
- `pKnown = 0.5` → multiplier 2.5 (learning)
|
||||
- `pKnown = 1.0` → multiplier 1.0 (mastered)
|
||||
|
||||
**Fluency Discrete (fallback):**
|
||||
|
||||
- `effortless` → 1
|
||||
- `fluent` → 2
|
||||
- `rusty` → 3
|
||||
- `practicing` → 3
|
||||
- `not_practicing` → 4
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Backend Integration
|
||||
|
||||
**Files to modify:**
|
||||
|
||||
1. `src/utils/skillComplexity.ts`
|
||||
- Add `SkillCostCalculatorOptions` interface
|
||||
- Add `bktResults` and `useBktScaling` parameters
|
||||
- Implement continuous multiplier calculation
|
||||
|
||||
2. `src/lib/curriculum/session-planner.ts`
|
||||
- Add `getRecentSessionResults()` call
|
||||
- Compute BKT during session planning
|
||||
- Pass BKT results to cost calculator
|
||||
|
||||
3. `src/lib/curriculum/bkt/index.ts`
|
||||
- Export necessary types and functions
|
||||
|
||||
### Phase 2: Preference Setting
|
||||
|
||||
**Files to create/modify:**
|
||||
|
||||
1. `src/db/schema/player-curriculum.ts`
|
||||
- Add `problemGenerationMode` field
|
||||
|
||||
2. `drizzle/XXXX_add_problem_generation_mode.sql`
|
||||
- Migration to add column
|
||||
|
||||
3. `src/lib/curriculum/progress-manager.ts`
|
||||
- Add getter/setter for preference
|
||||
|
||||
4. `src/components/practice/StartSessionModal.tsx` (or equivalent)
|
||||
- Add toggle in expanded settings
|
||||
|
||||
### Phase 3: Skills Dashboard Consistency
|
||||
|
||||
**Files to modify:**
|
||||
|
||||
1. `src/app/practice/[studentId]/skills/SkillsClient.tsx`
|
||||
- Show complexity multiplier derived from P(known)
|
||||
- Add evidence breakdown
|
||||
- Show "what this means for problem generation"
|
||||
|
||||
2. `src/app/api/curriculum/[playerId]/bkt/route.ts`
|
||||
- Ensure same BKT computation as session planner
|
||||
|
||||
### Phase 4: Transparency & Education
|
||||
|
||||
**Files to create:**
|
||||
|
||||
1. `src/components/practice/BktExplainer.tsx`
|
||||
- "Learn more" modal content
|
||||
|
||||
2. `src/components/practice/SessionSummary.tsx` (enhance)
|
||||
- Show BKT changes after session
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### New Config Constants
|
||||
|
||||
Location: `src/lib/curriculum/config/bkt-integration.ts`
|
||||
|
||||
```typescript
|
||||
export const BKT_INTEGRATION_CONFIG = {
|
||||
/** Confidence threshold for trusting BKT over fluency */
|
||||
confidenceThreshold: 0.5,
|
||||
|
||||
/** Minimum multiplier (when pKnown = 1.0) */
|
||||
minMultiplier: 1.0,
|
||||
|
||||
/** Maximum multiplier (when pKnown = 0.0) */
|
||||
maxMultiplier: 4.0,
|
||||
|
||||
/** Number of recent sessions to load for BKT computation */
|
||||
sessionHistoryDepth: 50,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Design
|
||||
|
||||
### Ready to Practice Modal - Advanced Settings
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ▼ Advanced Settings │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ Problem Selection ││
|
||||
│ │ ││
|
||||
│ │ ○ Adaptive (recommended) ││
|
||||
│ │ Uses Bayesian inference to estimate pattern mastery. ││
|
||||
│ │ Problems adjust smoothly based on your performance. ││
|
||||
│ │ ││
|
||||
│ │ ○ Classic ││
|
||||
│ │ Uses streak-based fluency thresholds. ││
|
||||
│ │ Problems change when you hit mastery milestones. ││
|
||||
│ │ ││
|
||||
│ │ [?] Learn more about how problem selection works ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Enhanced Skill Card
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Pattern: Ten Complements +6 │
|
||||
│ │
|
||||
│ Mastery: ████████░░ 78% Confidence: High (0.72) │
|
||||
│ │
|
||||
│ Problem Generation Impact: │
|
||||
│ • Complexity multiplier: 1.66× (lower = easier problems) │
|
||||
│ • This pattern appears in review and mixed practice │
|
||||
│ │
|
||||
│ Evidence: │
|
||||
│ • 47 problems • 89% accuracy • Avg 4.2s • 4 hints used │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit tests:** `createSkillCostCalculator` with/without BKT
|
||||
2. **Integration tests:** Session planning produces valid plans in both modes
|
||||
3. **Consistency tests:** Same BKT input → same output in dashboard and generation
|
||||
4. **Manual testing:** Toggle preference, verify behavior changes
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ----------------------------- | ---------------------------------- |
|
||||
| Performance (loading history) | Load in parallel; consider caching |
|
||||
| Cold start (no data) | Automatic fluency fallback |
|
||||
| User confusion | Clear explanations, "Learn more" |
|
||||
| Dashboard/generation mismatch | Single BKT computation source |
|
||||
|
||||
---
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
After implementation, update:
|
||||
|
||||
- `docs/DAILY_PRACTICE_SYSTEM.md` - Add BKT integration section
|
||||
- `.claude/CLAUDE.md` - Add BKT integration notes
|
||||
- Blog post - Update to reflect actual integration
|
||||
608
apps/web/.claude/CELEBRATION_WIND_DOWN_PLAN.md
Normal file
608
apps/web/.claude/CELEBRATION_WIND_DOWN_PLAN.md
Normal file
@@ -0,0 +1,608 @@
|
||||
# Celebration Wind-Down: The Proper Way
|
||||
|
||||
## Concept
|
||||
|
||||
Every single CSS property morphs individually from celebration state to normal state over ~60 seconds. No cheating with cross-fades. Pure interpolation madness.
|
||||
|
||||
## SIMPLIFICATION: Same Text Throughout
|
||||
|
||||
To make the transition truly seamless, the text content stays the same from start to finish:
|
||||
|
||||
- **Title**: "New Skill Unlocked: +5 − 3" (same throughout)
|
||||
- **Subtitle**: "Ready to start the tutorial" (same throughout)
|
||||
- **Button**: "Begin Tutorial →" (same throughout)
|
||||
|
||||
Only the _styling_ of the text changes (size, color, shadow) - not the content.
|
||||
This eliminates 6 properties that were doing text cross-fades.
|
||||
|
||||
## Properties to Interpolate
|
||||
|
||||
### Container
|
||||
|
||||
| Property | Celebration | Normal | Interpolation |
|
||||
| -------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| background | `linear-gradient(135deg, rgba(234,179,8,0.25), rgba(251,191,36,0.15), rgba(234,179,8,0.25))` | `linear-gradient(135deg, rgba(59,130,246,0.15), rgba(99,102,241,0.1))` | RGB channels per stop |
|
||||
| border-width | `3px` | `1px` | numeric |
|
||||
| border-color | `yellow.500` (#eab308) | `blue.500` (#3b82f6) | RGB |
|
||||
| border-radius | `16px` | `12px` | numeric |
|
||||
| padding | `1.5rem` (24px) | `0.75rem` (12px) | numeric |
|
||||
| box-shadow | `0 0 20px rgba(234,179,8,0.4), 0 0 40px rgba(234,179,8,0.2)` | `0 2px 8px rgba(0,0,0,0.1)` | multiple shadows, each with color+blur+spread |
|
||||
| text-align | `center` | `left` | discrete flip at 50%? Or use justify-content |
|
||||
| flex-direction | `column` | `row` | discrete flip |
|
||||
|
||||
### Emoji/Icon
|
||||
|
||||
| Property | Celebration | Normal |
|
||||
| ------------- | ---------------------------------------- | --------------- | --------------------------------- |
|
||||
| font-size | `4rem` (64px) | `1.5rem` (24px) | numeric |
|
||||
| opacity (🏆) | `1` | `0` | numeric |
|
||||
| opacity (🎓) | `0` | `1` | numeric |
|
||||
| transform | `rotate(-3deg)` to `rotate(3deg)` wiggle | `rotate(0)` | numeric (animation amplitude → 0) |
|
||||
| margin-bottom | `0.5rem` | `0` | numeric |
|
||||
|
||||
### Title Text
|
||||
|
||||
| Property | Celebration | Normal |
|
||||
| ------------------------------- | ------------------------------ | -------------------------- | ---------- |
|
||||
| font-size | `1.75rem` (28px) | `1rem` (16px) | numeric |
|
||||
| font-weight | `bold` (700) | `600` | numeric |
|
||||
| color | `yellow.200` (#fef08a) | `blue.700` (#1d4ed8) | RGB |
|
||||
| text-shadow | `0 0 20px rgba(234,179,8,0.5)` | `none` (0 0 0 transparent) | color+blur |
|
||||
| margin-bottom | `0.5rem` | `0.25rem` | numeric |
|
||||
| opacity ("New Skill Unlocked!") | `1` | `0` | numeric |
|
||||
| opacity ("Ready to Learn") | `0` | `1` | numeric |
|
||||
|
||||
### Subtitle Text
|
||||
|
||||
| Property | Celebration | Normal |
|
||||
| -------------------------- | ---------------- | ----------------- | ------- |
|
||||
| font-size | `1.25rem` (20px) | `0.875rem` (14px) | numeric |
|
||||
| color | `gray.200` | `gray.600` | RGB |
|
||||
| margin-bottom | `1rem` | `0` | numeric |
|
||||
| opacity (celebration text) | `1` | `0` | numeric |
|
||||
| opacity (normal text) | `0` | `1` | numeric |
|
||||
|
||||
### CTA Button
|
||||
|
||||
| Property | Celebration | Normal |
|
||||
| ----------------- | ------------------------------------------- | --------------------------- | -------------------- |
|
||||
| padding-x | `2rem` (32px) | `1rem` (16px) | numeric |
|
||||
| padding-y | `0.75rem` (12px) | `0.5rem` (8px) | numeric |
|
||||
| font-size | `1.125rem` (18px) | `0.875rem` (14px) | numeric |
|
||||
| background | `linear-gradient(135deg, #FCD34D, #F59E0B)` | `#3b82f6` | RGB gradient → solid |
|
||||
| border-radius | `12px` | `8px` | numeric |
|
||||
| box-shadow | `0 4px 15px rgba(245,158,11,0.4)` | `0 2px 4px rgba(0,0,0,0.1)` | color+offset+blur |
|
||||
| color | `gray.900` (#111827) | `white` (#ffffff) | RGB |
|
||||
| transform (hover) | `scale(1.05)` | `scale(1.02)` | numeric |
|
||||
|
||||
### Shimmer Overlay
|
||||
|
||||
| Property | Celebration | Normal |
|
||||
| ------------------ | ----------- | ---------------------------------- | ------- |
|
||||
| opacity | `1` | `0` | numeric |
|
||||
| animation-duration | `2s` | `10s` (slow to imperceptible stop) | numeric |
|
||||
|
||||
### Glow Animation
|
||||
|
||||
| Property | Celebration | Normal |
|
||||
| -------------------- | ----------- | ------ | -------------------------- |
|
||||
| box-shadow intensity | `1` | `0` | multiplier on shadow alpha |
|
||||
| animation amplitude | full | `0` | numeric |
|
||||
|
||||
## Interpolation Utilities
|
||||
|
||||
```typescript
|
||||
// Basic linear interpolation
|
||||
function lerp(start: number, end: number, t: number): number {
|
||||
return start + (end - start) * t;
|
||||
}
|
||||
|
||||
// Color interpolation (RGB)
|
||||
function lerpColor(startHex: string, endHex: string, t: number): string {
|
||||
const start = hexToRgb(startHex);
|
||||
const end = hexToRgb(endHex);
|
||||
return `rgb(${lerp(start.r, end.r, t)}, ${lerp(start.g, end.g, t)}, ${lerp(start.b, end.b, t)})`;
|
||||
}
|
||||
|
||||
// RGBA interpolation
|
||||
function lerpRgba(start: RGBA, end: RGBA, t: number): string {
|
||||
return `rgba(${lerp(start.r, end.r, t)}, ${lerp(start.g, end.g, t)}, ${lerp(start.b, end.b, t)}, ${lerp(start.a, end.a, t)})`;
|
||||
}
|
||||
|
||||
// Gradient interpolation (same number of stops)
|
||||
function lerpGradient(
|
||||
startStops: GradientStop[],
|
||||
endStops: GradientStop[],
|
||||
t: number,
|
||||
): string {
|
||||
const interpolatedStops = startStops.map((start, i) => {
|
||||
const end = endStops[i];
|
||||
return {
|
||||
color: lerpRgba(start.color, end.color, t),
|
||||
position: lerp(start.position, end.position, t),
|
||||
};
|
||||
});
|
||||
return `linear-gradient(135deg, ${interpolatedStops.map((s) => `${s.color} ${s.position}%`).join(", ")})`;
|
||||
}
|
||||
|
||||
// Box shadow interpolation
|
||||
function lerpBoxShadow(
|
||||
start: BoxShadow[],
|
||||
end: BoxShadow[],
|
||||
t: number,
|
||||
): string {
|
||||
// Pad shorter array with transparent shadows
|
||||
const maxLen = Math.max(start.length, end.length);
|
||||
const paddedStart = padShadows(start, maxLen);
|
||||
const paddedEnd = padShadows(end, maxLen);
|
||||
|
||||
return paddedStart
|
||||
.map((s, i) => {
|
||||
const e = paddedEnd[i];
|
||||
return `${lerp(s.x, e.x, t)}px ${lerp(s.y, e.y, t)}px ${lerp(s.blur, e.blur, t)}px ${lerp(s.spread, e.spread, t)}px ${lerpRgba(s.color, e.color, t)}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
```
|
||||
|
||||
## Timing Function
|
||||
|
||||
Ultra-slow ease-out that feels imperceptible:
|
||||
|
||||
```typescript
|
||||
function windDownProgress(elapsedMs: number): number {
|
||||
const BURST_DURATION = 5_000; // 5s full celebration
|
||||
const WIND_DOWN_DURATION = 55_000; // 55s transition
|
||||
|
||||
if (elapsedMs < BURST_DURATION) return 0;
|
||||
|
||||
const windDownElapsed = elapsedMs - BURST_DURATION;
|
||||
if (windDownElapsed >= WIND_DOWN_DURATION) return 1;
|
||||
|
||||
const t = windDownElapsed / WIND_DOWN_DURATION;
|
||||
|
||||
// Attempt: Start EXTREMELY slow, accelerate near end
|
||||
// Using quartic ease-out: 1 - (1-t)^4
|
||||
// But even slower: quintic ease-out: 1 - (1-t)^5
|
||||
return 1 - Math.pow(1 - t, 5);
|
||||
}
|
||||
```
|
||||
|
||||
Progress over time with quintic ease-out:
|
||||
|
||||
- 10s: 0.03% transitioned (imperceptible)
|
||||
- 20s: 0.8% transitioned (still imperceptible)
|
||||
- 30s: 4% transitioned (barely noticeable if you squint)
|
||||
- 40s: 13% transitioned (hmm, something's different?)
|
||||
- 50s: 33% transitioned (ok it's changing)
|
||||
- 55s: 52% transitioned
|
||||
- 58s: 75% transitioned
|
||||
- 60s: 100% done
|
||||
|
||||
## Animation Amplitude Wind-Down
|
||||
|
||||
For the wiggle animation on the trophy:
|
||||
|
||||
```typescript
|
||||
// Current wiggle: rotate between -3deg and +3deg
|
||||
// Wind down: amplitude goes from 3 → 0
|
||||
|
||||
function getWiggleAmplitude(t: number): number {
|
||||
// Inverse of progress - starts at 3, ends at 0
|
||||
return 3 * (1 - t)
|
||||
}
|
||||
|
||||
// In CSS/style:
|
||||
const wiggleAmplitude = getWiggleAmplitude(progress)
|
||||
// Use CSS custom property or inline keyframes
|
||||
style={{
|
||||
animation: wiggleAmplitude > 0.1
|
||||
? `wiggle-${Math.round(wiggleAmplitude * 10)} 0.5s ease-in-out infinite`
|
||||
: 'none'
|
||||
}}
|
||||
```
|
||||
|
||||
Actually, for smooth wiggle wind-down, we should use a spring-based approach or just interpolate the transform directly:
|
||||
|
||||
```typescript
|
||||
// Wiggle is a sine wave with decreasing amplitude
|
||||
const time = Date.now() / 500; // oscillation period
|
||||
const amplitude = 3 * (1 - progress);
|
||||
const rotation = Math.sin(time) * amplitude;
|
||||
// transform: `rotate(${rotation}deg)`
|
||||
```
|
||||
|
||||
## Component Structure
|
||||
|
||||
```typescript
|
||||
interface CelebrationStyles {
|
||||
// Container
|
||||
containerBackground: string;
|
||||
containerBorderWidth: number;
|
||||
containerBorderColor: string;
|
||||
containerBorderRadius: number;
|
||||
containerPadding: number;
|
||||
containerBoxShadow: string;
|
||||
containerFlexDirection: "column" | "row";
|
||||
containerAlignItems: "center" | "flex-start";
|
||||
containerTextAlign: "center" | "left";
|
||||
|
||||
// Emoji
|
||||
trophyOpacity: number;
|
||||
graduationCapOpacity: number;
|
||||
emojiSize: number;
|
||||
emojiRotation: number;
|
||||
emojiMarginBottom: number;
|
||||
|
||||
// Title
|
||||
titleFontSize: number;
|
||||
titleColor: string;
|
||||
titleTextShadow: string;
|
||||
titleMarginBottom: number;
|
||||
celebrationTitleOpacity: number;
|
||||
normalTitleOpacity: number;
|
||||
|
||||
// Subtitle
|
||||
subtitleFontSize: number;
|
||||
subtitleColor: string;
|
||||
subtitleMarginBottom: number;
|
||||
celebrationSubtitleOpacity: number;
|
||||
normalSubtitleOpacity: number;
|
||||
|
||||
// Button
|
||||
buttonPaddingX: number;
|
||||
buttonPaddingY: number;
|
||||
buttonFontSize: number;
|
||||
buttonBackground: string;
|
||||
buttonBorderRadius: number;
|
||||
buttonBoxShadow: string;
|
||||
buttonColor: string;
|
||||
|
||||
// Shimmer
|
||||
shimmerOpacity: number;
|
||||
|
||||
// Glow
|
||||
glowIntensity: number;
|
||||
}
|
||||
|
||||
function calculateStyles(progress: number, isDark: boolean): CelebrationStyles {
|
||||
const t = progress; // 0 = celebration, 1 = normal
|
||||
|
||||
return {
|
||||
// Container
|
||||
containerBackground: lerpGradient(
|
||||
isDark ? DARK_CELEBRATION_BG : LIGHT_CELEBRATION_BG,
|
||||
isDark ? DARK_NORMAL_BG : LIGHT_NORMAL_BG,
|
||||
t,
|
||||
),
|
||||
containerBorderWidth: lerp(3, 1, t),
|
||||
containerBorderColor: lerpColor("#eab308", "#3b82f6", t),
|
||||
containerBorderRadius: lerp(16, 12, t),
|
||||
containerPadding: lerp(24, 12, t),
|
||||
containerBoxShadow: lerpBoxShadow(CELEBRATION_SHADOWS, NORMAL_SHADOWS, t),
|
||||
containerFlexDirection: t < 0.5 ? "column" : "row",
|
||||
containerAlignItems: t < 0.5 ? "center" : "flex-start",
|
||||
containerTextAlign: t < 0.5 ? "center" : "left",
|
||||
|
||||
// Emoji - cross-fade between trophy and graduation cap
|
||||
trophyOpacity: 1 - t,
|
||||
graduationCapOpacity: t,
|
||||
emojiSize: lerp(64, 24, t),
|
||||
emojiRotation: Math.sin(Date.now() / 500) * 3 * (1 - t),
|
||||
emojiMarginBottom: lerp(8, 0, t),
|
||||
|
||||
// Title
|
||||
titleFontSize: lerp(28, 16, t),
|
||||
titleColor: lerpColor(
|
||||
isDark ? "#fef08a" : "#a16207",
|
||||
isDark ? "#93c5fd" : "#1d4ed8",
|
||||
t,
|
||||
),
|
||||
titleTextShadow: `0 0 ${lerp(20, 0, t)}px rgba(234,179,8,${lerp(0.5, 0, t)})`,
|
||||
titleMarginBottom: lerp(8, 4, t),
|
||||
celebrationTitleOpacity: 1 - t,
|
||||
normalTitleOpacity: t,
|
||||
|
||||
// Subtitle
|
||||
subtitleFontSize: lerp(20, 14, t),
|
||||
subtitleColor: lerpColor(
|
||||
isDark ? "#e5e7eb" : "#374151",
|
||||
isDark ? "#9ca3af" : "#4b5563",
|
||||
t,
|
||||
),
|
||||
subtitleMarginBottom: lerp(16, 0, t),
|
||||
celebrationSubtitleOpacity: 1 - t,
|
||||
normalSubtitleOpacity: t,
|
||||
|
||||
// Button
|
||||
buttonPaddingX: lerp(32, 16, t),
|
||||
buttonPaddingY: lerp(12, 8, t),
|
||||
buttonFontSize: lerp(18, 14, t),
|
||||
buttonBackground: lerpGradient(CELEBRATION_BUTTON_BG, NORMAL_BUTTON_BG, t),
|
||||
buttonBorderRadius: lerp(12, 8, t),
|
||||
buttonBoxShadow: lerpBoxShadow(
|
||||
CELEBRATION_BUTTON_SHADOW,
|
||||
NORMAL_BUTTON_SHADOW,
|
||||
t,
|
||||
),
|
||||
buttonColor: lerpColor("#111827", "#ffffff", t),
|
||||
|
||||
// Effects
|
||||
shimmerOpacity: 1 - t,
|
||||
glowIntensity: 1 - t,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Render Logic
|
||||
|
||||
```tsx
|
||||
function CelebrationProgressionBanner({
|
||||
sessionMode,
|
||||
onAction,
|
||||
variant,
|
||||
isDark,
|
||||
}: Props) {
|
||||
const skillId = sessionMode.nextSkill.skillId;
|
||||
const { progress, shouldFireConfetti, oscillation } =
|
||||
useCelebrationWindDown(skillId);
|
||||
|
||||
// Fire confetti once
|
||||
useEffect(() => {
|
||||
if (shouldFireConfetti) {
|
||||
fireConfettiCelebration();
|
||||
}
|
||||
}, [shouldFireConfetti]);
|
||||
|
||||
// Calculate all interpolated styles
|
||||
const styles = calculateStyles(progress, isDark);
|
||||
|
||||
// For layout transition (column → row), we need to handle this carefully
|
||||
// Use flexbox with animated flex-direction doesn't work well
|
||||
// Instead: use a wrapper that morphs via width/height constraints
|
||||
|
||||
return (
|
||||
<div
|
||||
data-element="session-mode-banner"
|
||||
data-celebration-progress={progress}
|
||||
style={{
|
||||
position: "relative",
|
||||
background: styles.containerBackground,
|
||||
borderWidth: `${styles.containerBorderWidth}px`,
|
||||
borderStyle: "solid",
|
||||
borderColor: styles.containerBorderColor,
|
||||
borderRadius: `${styles.containerBorderRadius}px`,
|
||||
padding: `${styles.containerPadding}px`,
|
||||
boxShadow: styles.containerBoxShadow,
|
||||
display: "flex",
|
||||
flexDirection: styles.containerFlexDirection,
|
||||
alignItems: styles.containerAlignItems,
|
||||
textAlign: styles.containerTextAlign,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Shimmer overlay - fades out */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background:
|
||||
"linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.2) 50%, transparent 100%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer 2s linear infinite",
|
||||
opacity: styles.shimmerOpacity,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Emoji container - both emojis positioned, cross-fading */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
fontSize: `${styles.emojiSize}px`,
|
||||
marginBottom: `${styles.emojiMarginBottom}px`,
|
||||
marginRight: styles.containerFlexDirection === "row" ? "12px" : 0,
|
||||
}}
|
||||
>
|
||||
{/* Trophy - fades out, wiggles */}
|
||||
<span
|
||||
style={{
|
||||
opacity: styles.trophyOpacity,
|
||||
transform: `rotate(${styles.emojiRotation}deg)`,
|
||||
position: styles.trophyOpacity < 0.5 ? "absolute" : "relative",
|
||||
}}
|
||||
>
|
||||
🏆
|
||||
</span>
|
||||
{/* Graduation cap - fades in */}
|
||||
<span
|
||||
style={{
|
||||
opacity: styles.graduationCapOpacity,
|
||||
position:
|
||||
styles.graduationCapOpacity < 0.5 ? "absolute" : "relative",
|
||||
}}
|
||||
>
|
||||
🎓
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Text content area */}
|
||||
<div style={{ flex: 1 }}>
|
||||
{/* Title - both versions, cross-fading */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
fontSize: `${styles.titleFontSize}px`,
|
||||
fontWeight: "bold",
|
||||
color: styles.titleColor,
|
||||
textShadow: styles.titleTextShadow,
|
||||
marginBottom: `${styles.titleMarginBottom}px`,
|
||||
}}
|
||||
>
|
||||
<span style={{ opacity: styles.celebrationTitleOpacity }}>
|
||||
New Skill Unlocked!
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
opacity: styles.normalTitleOpacity,
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
Ready to Learn New Skill
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subtitle - both versions, cross-fading */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
fontSize: `${styles.subtitleFontSize}px`,
|
||||
color: styles.subtitleColor,
|
||||
marginBottom: `${styles.subtitleMarginBottom}px`,
|
||||
}}
|
||||
>
|
||||
<span style={{ opacity: styles.celebrationSubtitleOpacity }}>
|
||||
You're ready to learn{" "}
|
||||
<strong>{sessionMode.nextSkill.displayName}</strong>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
opacity: styles.normalSubtitleOpacity,
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
{sessionMode.nextSkill.displayName} — Start the tutorial to begin
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button */}
|
||||
<button
|
||||
onClick={onAction}
|
||||
style={{
|
||||
padding: `${styles.buttonPaddingY}px ${styles.buttonPaddingX}px`,
|
||||
fontSize: `${styles.buttonFontSize}px`,
|
||||
fontWeight: "bold",
|
||||
background: styles.buttonBackground,
|
||||
color: styles.buttonColor,
|
||||
borderRadius: `${styles.buttonBorderRadius}px`,
|
||||
border: "none",
|
||||
boxShadow: styles.buttonBoxShadow,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{/* Button text also cross-fades */}
|
||||
<span style={{ opacity: styles.celebrationTitleOpacity }}>
|
||||
Start Learning!
|
||||
</span>
|
||||
<span
|
||||
style={{ opacity: styles.normalTitleOpacity, position: "absolute" }}
|
||||
>
|
||||
Start Tutorial
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Animation Frame Loop
|
||||
|
||||
The wind-down needs to run on requestAnimationFrame for smooth updates:
|
||||
|
||||
```typescript
|
||||
function useCelebrationWindDown(skillId: string) {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [shouldFireConfetti, setShouldFireConfetti] = useState(false);
|
||||
const [oscillation, setOscillation] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const state = getCelebrationState(skillId);
|
||||
|
||||
if (!state) {
|
||||
// First time seeing this skill unlock
|
||||
setCelebrationState(skillId, {
|
||||
startedAt: Date.now(),
|
||||
confettiFired: false,
|
||||
});
|
||||
setShouldFireConfetti(true);
|
||||
}
|
||||
|
||||
let rafId: number;
|
||||
const animate = () => {
|
||||
const state = getCelebrationState(skillId);
|
||||
if (!state) return;
|
||||
|
||||
const elapsed = Date.now() - state.startedAt;
|
||||
const newProgress = windDownProgress(elapsed);
|
||||
|
||||
setProgress(newProgress);
|
||||
setOscillation(Math.sin(Date.now() / 500)); // For wiggle
|
||||
|
||||
if (newProgress < 1) {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [skillId]);
|
||||
|
||||
return { progress, shouldFireConfetti, oscillation };
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Create interpolation utilities** (`src/utils/interpolate.ts`)
|
||||
- `lerp(start, end, t)`
|
||||
- `hexToRgb(hex)`, `rgbToHex(r, g, b)`
|
||||
- `lerpColor(startHex, endHex, t)`
|
||||
- `lerpRgba(start, end, t)`
|
||||
- `parseGradient(css)`, `lerpGradient(start, end, t)`
|
||||
- `parseBoxShadow(css)`, `lerpBoxShadow(start, end, t)`
|
||||
|
||||
2. **Create wind-down hook** (`src/hooks/useCelebrationWindDown.ts`)
|
||||
- localStorage state management
|
||||
- requestAnimationFrame loop
|
||||
- Progress calculation with quintic ease-out
|
||||
- Confetti trigger flag
|
||||
|
||||
3. **Create style calculation** (in SessionModeBanner or separate file)
|
||||
- Define start/end values for all properties
|
||||
- `calculateCelebrationStyles(progress, isDark)`
|
||||
|
||||
4. **Update SessionModeBanner**
|
||||
- Add CelebrationProgressionBanner sub-component
|
||||
- Integrate wind-down when progression + tutorialRequired
|
||||
- Move confetti firing into banner
|
||||
|
||||
5. **Clean up Dashboard/Summary**
|
||||
- Remove SkillUnlockBanner conditionals
|
||||
- Let SessionModeBanner handle everything
|
||||
|
||||
6. **Consider: SkillUnlockBanner**
|
||||
- Deprecate or keep for other uses?
|
||||
- Could extract confetti logic to shared util
|
||||
|
||||
## Total Property Count
|
||||
|
||||
We're interpolating:
|
||||
|
||||
**Container:** 6 properties (background, border-width, border-color, border-radius, padding, box-shadow)
|
||||
**Emoji:** 5 properties (trophy opacity, star opacity, size, rotation, margin)
|
||||
**Title:** 3 properties (font-size, color, text-shadow)
|
||||
**Subtitle:** 3 properties (font-size, color, margin-top)
|
||||
**Button:** 7 properties (padding-y, padding-x, font-size, background, border-radius, box-shadow, color)
|
||||
**Effects:** 1 property (shimmer opacity)
|
||||
**Layout:** 1 property (flex-direction/alignment switch at 70%)
|
||||
|
||||
**Total: 26 interpolated properties**
|
||||
|
||||
Plus the oscillation for the wiggle animation running independently at 60fps.
|
||||
|
||||
This is properly ridiculous. The text stays the same throughout, making the transition truly imperceptible.
|
||||
@@ -42,6 +42,7 @@ When you agree with the user on a technical approach (e.g., "use getBBox() for b
|
||||
3. **When fixes don't work, FIRST verify the agreed approach was actually implemented everywhere** - don't add patches on top of a broken foundation
|
||||
|
||||
**The failure pattern:**
|
||||
|
||||
- User and Claude agree: "Part 1 and Part 2 should both use method X"
|
||||
- Claude implements method X for Part 2 (the obvious case)
|
||||
- Claude leaves Part 1 using the old method Y
|
||||
@@ -50,11 +51,13 @@ When you agree with the user on a technical approach (e.g., "use getBBox() for b
|
||||
- Cycle repeats until user is frustrated
|
||||
|
||||
**What to do instead:**
|
||||
|
||||
- Before implementing: "Part 1 will use [exact method], Part 2 will use [exact method]"
|
||||
- After implementing: Verify BOTH actually use the agreed method
|
||||
- When debugging: First question should be "did I actually implement what we agreed on everywhere?"
|
||||
|
||||
**Why this matters:**
|
||||
|
||||
- Users cannot verify every line of code you write
|
||||
- They trust that when you agree to do something, you actually do it
|
||||
- Superficial fixes waste everyone's time when the root cause is incomplete implementation
|
||||
@@ -88,6 +91,161 @@ README.md (root)
|
||||
**Invalid:** Creating `/docs/some-feature.md` without linking from anywhere ❌
|
||||
**Valid:** Creating `/docs/some-feature.md` AND linking from root README ✅
|
||||
|
||||
## CRITICAL: Never Directly Modify Database Schema
|
||||
|
||||
**NEVER modify the database schema directly (e.g., via sqlite3, manual SQL, or direct ALTER TABLE commands).**
|
||||
|
||||
All database schema changes MUST go through the Drizzle migration system:
|
||||
|
||||
1. **Modify the schema file** in `src/db/schema/`
|
||||
2. **Generate a migration** using `npx drizzle-kit generate --custom`
|
||||
3. **Edit the generated SQL file** with the actual migration statements
|
||||
4. **Run the migration** using `npm run db:migrate`
|
||||
5. **Commit both** the schema change AND the migration file
|
||||
|
||||
**Why this matters:**
|
||||
|
||||
- The migration system tracks which changes have been applied
|
||||
- Production runs migrations on startup to sync schema with code
|
||||
- If you modify the DB directly, the migration system doesn't know about it
|
||||
- When you later create a migration for the "same" change, it becomes a no-op locally but fails on production
|
||||
|
||||
**The failure pattern (December 2025):**
|
||||
|
||||
1. During development, columns were added directly to local DB (bypassing migrations)
|
||||
2. Migration 0043 was created but as `SELECT 1;` (no-op) because "columns already exist"
|
||||
3. Production ran 0043 (no-op), so it never got the columns
|
||||
4. Production crashed with "no such column: is_paused"
|
||||
5. Required emergency migration 0044 to fix
|
||||
|
||||
**The correct pattern:**
|
||||
|
||||
```bash
|
||||
# 1. Modify schema file
|
||||
# 2. Generate migration
|
||||
npx drizzle-kit generate --custom
|
||||
|
||||
# 3. Edit the generated SQL file with actual ALTER TABLE statements
|
||||
# 4. Test locally
|
||||
npm run db:migrate
|
||||
|
||||
# 5. Commit both schema and migration
|
||||
git add src/db/schema/ drizzle/
|
||||
git commit -m "feat: add new column"
|
||||
```
|
||||
|
||||
**Never do this:**
|
||||
|
||||
```bash
|
||||
# ❌ WRONG - Direct database modification
|
||||
sqlite3 data/sqlite.db "ALTER TABLE session_plans ADD COLUMN is_paused INTEGER;"
|
||||
|
||||
# ❌ WRONG - Then creating a no-op migration because "column exists"
|
||||
# drizzle/0043.sql:
|
||||
# SELECT 1; -- This does nothing on production!
|
||||
```
|
||||
|
||||
## CRITICAL: Never Modify Migration Files After Deployment
|
||||
|
||||
**NEVER modify a migration file after it has been deployed to production.**
|
||||
|
||||
Drizzle tracks migrations by name/tag, not by content. Once a migration is recorded in `__drizzle_migrations`, it will NEVER be re-run, even if the file content changes.
|
||||
|
||||
### The Failure Pattern (December 2025)
|
||||
|
||||
1. Migration 0047 was created and deployed (possibly as a stub or incomplete)
|
||||
2. Production recorded it in `__drizzle_migrations` as "applied"
|
||||
3. Developer modified 0047.sql locally with the actual CREATE TABLE statement
|
||||
4. New deployment saw 0047 was already "applied" → skipped it
|
||||
5. Production crashed: `SqliteError: no such column: "entry_prompt_expiry_minutes"`
|
||||
6. Required emergency migration to fix
|
||||
|
||||
**This exact pattern has caused THREE production outages:**
|
||||
|
||||
- Migration 0043 (December 2025): `is_paused` column missing
|
||||
- Migration 0047 (December 2025): `entry_prompts` table missing
|
||||
- Migration 0048 (December 2025): `entry_prompt_expiry_minutes` column missing
|
||||
|
||||
### Why This Happens
|
||||
|
||||
```
|
||||
Timeline:
|
||||
1. Create migration file (empty or stub) → deployed → recorded as "applied"
|
||||
2. Modify migration file with real SQL → deployed → SKIPPED (already "applied")
|
||||
3. Production crashes → missing tables/columns
|
||||
```
|
||||
|
||||
Drizzle's migrator checks: "Is migration 0047 in `__drizzle_migrations`?" → Yes → Skip it.
|
||||
It does NOT check: "Has the content of 0047.sql changed?"
|
||||
|
||||
### The Correct Pattern
|
||||
|
||||
**Before committing a migration, ensure it contains the FINAL SQL:**
|
||||
|
||||
```bash
|
||||
# 1. Generate migration
|
||||
npx drizzle-kit generate --custom
|
||||
|
||||
# 2. IMMEDIATELY edit the generated SQL with the actual statements
|
||||
# DO NOT commit an empty/stub migration!
|
||||
|
||||
# 3. Run locally to verify
|
||||
npm run db:migrate
|
||||
|
||||
# 4. Only THEN commit
|
||||
git add drizzle/
|
||||
git commit -m "feat: add entry_prompts table"
|
||||
```
|
||||
|
||||
### If You Need to Fix a Deployed Migration
|
||||
|
||||
**DO NOT modify the existing migration file.** Instead:
|
||||
|
||||
```bash
|
||||
# Create a NEW migration with the fix
|
||||
npx drizzle-kit generate --custom
|
||||
# Name it something like: 0050_fix_missing_entry_prompts.sql
|
||||
|
||||
# Add the missing SQL (with IF NOT EXISTS for safety)
|
||||
CREATE TABLE IF NOT EXISTS `entry_prompts` (...);
|
||||
# OR for SQLite (which doesn't support IF NOT EXISTS for columns):
|
||||
# Check if column exists first, or just let it fail silently
|
||||
```
|
||||
|
||||
### Red Flags
|
||||
|
||||
If you find yourself:
|
||||
|
||||
- Editing a migration file that's already been committed
|
||||
- Thinking "I'll just update this migration with the correct SQL"
|
||||
- Seeing "migration already applied" but schema is wrong
|
||||
|
||||
**STOP.** Create a NEW migration instead.
|
||||
|
||||
### Emergency Fix for Production
|
||||
|
||||
If production is down due to missing schema, create a new migration immediately:
|
||||
|
||||
```bash
|
||||
# 1. Generate emergency migration
|
||||
npx drizzle-kit generate --custom
|
||||
# Creates: drizzle/0050_emergency_fix.sql
|
||||
|
||||
# 2. Add the missing SQL with safety checks
|
||||
# For tables:
|
||||
CREATE TABLE IF NOT EXISTS `entry_prompts` (...);
|
||||
|
||||
# For columns (SQLite workaround - will error if exists, but migration still records):
|
||||
ALTER TABLE `classrooms` ADD COLUMN `entry_prompt_expiry_minutes` integer;
|
||||
|
||||
# 3. Commit and deploy
|
||||
git add drizzle/
|
||||
git commit -m "fix: emergency migration for missing schema"
|
||||
git push
|
||||
```
|
||||
|
||||
The new migration will run on production startup and fix the schema.
|
||||
|
||||
## CRITICAL: @svg-maps ES Module Imports Work Correctly
|
||||
|
||||
**The @svg-maps packages (world, usa) USE ES module syntax and this WORKS correctly in production.**
|
||||
@@ -821,6 +979,49 @@ When adding/modifying database schema:
|
||||
- Production deployments run `npm run db:migrate` automatically
|
||||
- Improperly created migrations will fail in production
|
||||
|
||||
### CRITICAL: Statement Breakpoints in Migrations
|
||||
|
||||
**When a migration contains multiple SQL statements, you MUST add `--> statement-breakpoint` between them.**
|
||||
|
||||
Drizzle's better-sqlite3 driver executes statements one at a time. If you have multiple statements without breakpoints, the migration will fail with:
|
||||
|
||||
```
|
||||
RangeError: The supplied SQL string contains more than one statement
|
||||
```
|
||||
|
||||
**✅ CORRECT - Multiple statements with breakpoints:**
|
||||
|
||||
```sql
|
||||
-- Create the table
|
||||
CREATE TABLE `app_settings` (
|
||||
`id` text PRIMARY KEY DEFAULT 'default' NOT NULL,
|
||||
`threshold` real DEFAULT 0.3 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Seed default data
|
||||
INSERT INTO `app_settings` (`id`, `threshold`) VALUES ('default', 0.3);
|
||||
```
|
||||
|
||||
**❌ WRONG - Multiple statements without breakpoint (CAUSES PRODUCTION OUTAGE):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE `app_settings` (...);
|
||||
|
||||
-- This will fail!
|
||||
INSERT INTO `app_settings` ...;
|
||||
```
|
||||
|
||||
**When this applies:**
|
||||
|
||||
- CREATE TABLE followed by INSERT (seeding data)
|
||||
- CREATE TABLE followed by CREATE INDEX
|
||||
- Any migration with 2+ SQL statements
|
||||
|
||||
**Historical context:**
|
||||
|
||||
This mistake caused a production outage on 2025-12-18. The app crash-looped because migration 0035 had CREATE TABLE + INSERT without a breakpoint. Always verify migrations with multiple statements have `--> statement-breakpoint` markers.
|
||||
|
||||
## Deployment Verification
|
||||
|
||||
**CRITICAL: Never assume deployment is complete just because the website is accessible.**
|
||||
@@ -872,6 +1073,138 @@ ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-c
|
||||
|
||||
**Common mistake:** Seeing https://abaci.one is online and assuming the new code is deployed. Always verify the commit SHA.
|
||||
|
||||
## CRITICAL: React Query - Mutations Must Invalidate Related Queries
|
||||
|
||||
**This is a documented failure pattern. Do not repeat it.**
|
||||
|
||||
When modifying data that's fetched via React Query, you MUST use React Query mutations that properly invalidate the affected queries. Using plain `fetch()` + `router.refresh()` will NOT update the React Query cache.
|
||||
|
||||
### The Failure Pattern
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - This causes stale UI!
|
||||
const handleRefreshSkill = useCallback(
|
||||
async (skillId: string) => {
|
||||
const response = await fetch(`/api/curriculum/${studentId}/skills`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ skillId }),
|
||||
});
|
||||
router.refresh(); // Does NOT invalidate React Query cache!
|
||||
},
|
||||
[studentId, router],
|
||||
);
|
||||
```
|
||||
|
||||
**Why this fails:**
|
||||
|
||||
- `router.refresh()` triggers a Next.js server re-render
|
||||
- But React Query maintains its own cache on the client
|
||||
- The cached data stays stale until the query naturally refetches
|
||||
- User sees outdated UI until they reload the page
|
||||
|
||||
### The Correct Pattern
|
||||
|
||||
**Step 1: Check if a mutation hook already exists**
|
||||
|
||||
Look in `src/hooks/` for existing mutation hooks:
|
||||
|
||||
- `usePlayerCurriculum.ts` - curriculum mutations (`useRefreshSkillRecency`, `useSetMasteredSkills`)
|
||||
- `useSessionPlan.ts` - session plan mutations
|
||||
- `useRoomData.ts` - arcade room mutations
|
||||
|
||||
**Step 2: Use the existing hook**
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use the mutation hook
|
||||
import { useRefreshSkillRecency } from "@/hooks/usePlayerCurriculum";
|
||||
|
||||
function MyComponent({ studentId }) {
|
||||
const refreshSkillRecency = useRefreshSkillRecency();
|
||||
|
||||
const handleRefreshSkill = useCallback(
|
||||
async (skillId: string) => {
|
||||
await refreshSkillRecency.mutateAsync({ playerId: studentId, skillId });
|
||||
},
|
||||
[studentId, refreshSkillRecency],
|
||||
);
|
||||
|
||||
// Use mutation state for loading indicators
|
||||
const isRefreshing = refreshSkillRecency.isPending
|
||||
? refreshSkillRecency.variables?.skillId
|
||||
: null;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: If no hook exists, create one with proper invalidation**
|
||||
|
||||
```typescript
|
||||
// In src/hooks/usePlayerCurriculum.ts
|
||||
export function useRefreshSkillRecency() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
playerId,
|
||||
skillId,
|
||||
}: {
|
||||
playerId: string;
|
||||
skillId: string;
|
||||
}) => {
|
||||
const response = await api(`curriculum/${playerId}/skills`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ skillId }),
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to refresh skill");
|
||||
return response.json();
|
||||
},
|
||||
// THIS IS THE CRITICAL PART - invalidate affected queries!
|
||||
onSuccess: (_, { playerId }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: curriculumKeys.detail(playerId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Query Key Relationships
|
||||
|
||||
**Always ensure mutations invalidate the right query keys:**
|
||||
|
||||
| Mutation | Must Invalidate |
|
||||
| --------------------- | ---------------------------------- |
|
||||
| Skill mastery changes | `curriculumKeys.detail(playerId)` |
|
||||
| Session plan updates | `sessionPlanKeys.active(playerId)` |
|
||||
| Player settings | `curriculumKeys.detail(playerId)` |
|
||||
| Room settings | `roomKeys.detail(roomId)` |
|
||||
|
||||
**Query keys are defined in:** `src/lib/queryKeys.ts`
|
||||
|
||||
### Red Flags
|
||||
|
||||
If you find yourself:
|
||||
|
||||
- Using `fetch()` directly in a component for mutations
|
||||
- Calling `router.refresh()` after a data mutation
|
||||
- Creating `useState` for loading states instead of using `mutation.isPending`
|
||||
|
||||
**STOP.** Look for an existing mutation hook or create one with proper cache invalidation.
|
||||
|
||||
### Historical Context
|
||||
|
||||
**This exact bug has occurred multiple times:**
|
||||
|
||||
1. **SkillsTab "Mark Current" button (2025-12-20)**: Used `fetch` + `router.refresh()` instead of `useRefreshSkillRecency`. Skills list showed stale data until page reload.
|
||||
|
||||
2. **Similar patterns exist elsewhere** - always check before adding new mutation logic.
|
||||
|
||||
### Checklist Before Writing Mutation Code
|
||||
|
||||
1. [ ] Is there already a mutation hook in `src/hooks/`?
|
||||
2. [ ] Does the mutation invalidate the correct query key?
|
||||
3. [ ] Am I using `mutation.isPending` instead of manual loading state?
|
||||
4. [ ] Am I NOT using `router.refresh()` for cache updates?
|
||||
|
||||
## Daily Practice System
|
||||
|
||||
When working on the curriculum-based daily practice system, refer to:
|
||||
@@ -884,6 +1217,7 @@ When working on the curriculum-based daily practice system, refer to:
|
||||
- Database schema and API endpoints
|
||||
|
||||
**Key Files**:
|
||||
|
||||
- `src/lib/curriculum/progress-manager.ts` - CRUD operations
|
||||
- `src/hooks/usePlayerCurriculum.ts` - Client-side state management
|
||||
- `src/components/practice/` - UI components (StudentSelector, ProgressDashboard)
|
||||
|
||||
324
apps/web/.claude/COMPLEXITY_BUDGET_SYSTEM.md
Normal file
324
apps/web/.claude/COMPLEXITY_BUDGET_SYSTEM.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# Complexity Budget System
|
||||
|
||||
## Overview
|
||||
|
||||
The complexity budget system controls problem difficulty by measuring the cognitive cost of each term in a problem. This allows us to:
|
||||
|
||||
1. **Cap difficulty** for beginners (max budget) - don't overwhelm with too many hard skills per term
|
||||
2. **Require difficulty** for challenge problems (min budget) - ensure every term exercises real skills
|
||||
3. **Personalize difficulty** based on student mastery - same problem is "harder" for students still learning
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ SESSION PLANNER │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────────────────────────┐ │
|
||||
│ │ PlayerSkillMastery │───▶│ buildStudentSkillHistory() │ │
|
||||
│ │ (from DB) │ │ ↓ │ │
|
||||
│ └─────────────────────┘ │ StudentSkillHistory │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ createSkillCostCalculator() │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ SkillCostCalculator │──┐
|
||||
│ └─────────────────────────────────────────┘ │ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────────────────────────┐ │ │
|
||||
│ │ purposeComplexity │───▶│ getComplexityBoundsForSlot() │ │ │
|
||||
│ │ Bounds (config) │ │ ↓ │ │ │
|
||||
│ └─────────────────────┘ │ { min?: number, max?: number } │──┼─┐
|
||||
│ └─────────────────────────────────────────┘ │ │ │
|
||||
└──────────────────────────────────────────────────────────────────────────┘ │ │
|
||||
│ │
|
||||
┌─────────────────────────────────────────────────────────────────────────┐ │ │
|
||||
│ PROBLEM GENERATOR │ │ │
|
||||
│ │ │ │
|
||||
│ generateProblemFromConstraints(constraints, costCalculator) ◀───────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ For each candidate term: │ │
|
||||
│ │ termCost = costCalculator.calculateTermCost(stepSkills) │◀─┘
|
||||
│ │ │
|
||||
│ │ if (termCost > maxBudget) continue // Too hard │
|
||||
│ │ if (termCost < minBudget) continue // Too easy │
|
||||
│ │ │
|
||||
│ │ candidates.push({ term, skillsUsed, complexityCost: termCost }) │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ │ GenerationTrace (output) │
|
||||
│ │ - steps[].complexityCost │
|
||||
│ │ - totalComplexityCost │
|
||||
│ │ - minBudgetConstraint / budgetConstraint │
|
||||
│ │ - skillMasteryContext (per-skill mastery for display) │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Cost Calculation
|
||||
|
||||
### Base Skill Complexity (Intrinsic)
|
||||
|
||||
| Skill Category | Base Cost | Rationale |
|
||||
| ------------------------ | --------- | -------------------------- |
|
||||
| `basic.*` (direct moves) | 0 | Trivial bead movements |
|
||||
| `fiveComplements.*` | 1 | Single mental substitution |
|
||||
| `tenComplements.*` | 2 | Cross-column operation |
|
||||
| `advanced.cascading*` | 3 | Multi-column propagation |
|
||||
|
||||
### Mastery Multipliers (Student-Specific)
|
||||
|
||||
| Mastery State | Multiplier | Description |
|
||||
| ------------- | ---------- | --------------------------------- |
|
||||
| `effortless` | 1× | Automatic, no thought required |
|
||||
| `fluent` | 2× | Solid but needs some attention |
|
||||
| `practicing` | 3× | Currently working on, needs focus |
|
||||
| `learning` | 4× | Just introduced, maximum effort |
|
||||
|
||||
### Effective Cost Formula
|
||||
|
||||
```
|
||||
effectiveCost = baseCost × masteryMultiplier
|
||||
termCost = Σ(effectiveCost for each skill in term)
|
||||
```
|
||||
|
||||
**Example**: `5 + 9 = 14` requires `tenComplements.9=10-1`
|
||||
|
||||
- For a beginner (learning): `2 × 4 = 8`
|
||||
- For an expert (effortless): `2 × 1 = 2`
|
||||
|
||||
Same problem, different cognitive load.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Purpose-Specific Complexity Bounds
|
||||
|
||||
```typescript
|
||||
purposeComplexityBounds: {
|
||||
focus: {
|
||||
abacus: { min: null, max: null }, // Full range
|
||||
visualization: { min: null, max: 3 }, // Cap for mental math
|
||||
linear: { min: null, max: null },
|
||||
},
|
||||
reinforce: {
|
||||
abacus: { min: null, max: null },
|
||||
visualization: { min: null, max: 3 },
|
||||
linear: { min: null, max: null },
|
||||
},
|
||||
review: {
|
||||
abacus: { min: null, max: null },
|
||||
visualization: { min: null, max: 3 },
|
||||
linear: { min: null, max: null },
|
||||
},
|
||||
challenge: {
|
||||
abacus: { min: 1, max: null }, // Require complement skills
|
||||
visualization: { min: 1, max: null }, // No cap, require min
|
||||
linear: { min: 1, max: null },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### What the Bounds Mean
|
||||
|
||||
- **`min: null`** - Any term is acceptable, including trivial `+1` direct additions
|
||||
- **`min: 1`** - Every term must use at least one non-trivial skill (five-complement or higher)
|
||||
- **`max: 3`** - No term can exceed cost 3 (prevents overwhelming visualization)
|
||||
- **`max: null`** - No upper limit
|
||||
|
||||
## Data Flow
|
||||
|
||||
### 1. Session Planning
|
||||
|
||||
```typescript
|
||||
// session-planner.ts
|
||||
const skillMastery = await getAllSkillMastery(playerId);
|
||||
|
||||
// Build student-aware calculator
|
||||
const studentHistory = buildStudentSkillHistory(skillMastery);
|
||||
const costCalculator = createSkillCostCalculator(studentHistory);
|
||||
|
||||
// For each slot
|
||||
const bounds = getComplexityBoundsForSlot(purpose, partType, config);
|
||||
const slot = createSlot(index, purpose, constraints, partType, config);
|
||||
slot.complexityBounds = bounds;
|
||||
|
||||
// Generate problem with calculator
|
||||
slot.problem = generateProblemFromConstraints(slot.constraints, costCalculator);
|
||||
```
|
||||
|
||||
### 2. Problem Generation
|
||||
|
||||
```typescript
|
||||
// problem-generator.ts
|
||||
function generateProblemFromConstraints(
|
||||
constraints: ProblemConstraints,
|
||||
costCalculator?: SkillCostCalculator,
|
||||
): GeneratedProblem {
|
||||
// Pass through to generator
|
||||
const problem = generateSingleProblem({
|
||||
constraints: {
|
||||
...generatorConstraints,
|
||||
minComplexityBudgetPerTerm: constraints.minComplexityBudgetPerTerm,
|
||||
maxComplexityBudgetPerTerm: constraints.maxComplexityBudgetPerTerm,
|
||||
},
|
||||
allowedSkills,
|
||||
costCalculator,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Term Filtering
|
||||
|
||||
```typescript
|
||||
// problemGenerator.ts - findValidNextTermWithTrace
|
||||
const termCost = costCalculator?.calculateTermCost(stepSkills);
|
||||
|
||||
if (termCost !== undefined) {
|
||||
if (maxBudget !== undefined && termCost > maxBudget) continue;
|
||||
if (minBudget !== undefined && termCost < minBudget) continue;
|
||||
}
|
||||
|
||||
candidates.push({ term, skillsUsed, complexityCost: termCost });
|
||||
```
|
||||
|
||||
### 4. Trace Capture
|
||||
|
||||
```typescript
|
||||
// Captured in GenerationTrace
|
||||
{
|
||||
steps: [
|
||||
{ termAdded: 4, skillsUsed: ['fiveComplements.4=5-1'], complexityCost: 2 },
|
||||
{ termAdded: 9, skillsUsed: ['tenComplements.9=10-1'], complexityCost: 4 },
|
||||
],
|
||||
totalComplexityCost: 6,
|
||||
minBudgetConstraint: 1,
|
||||
budgetConstraint: null,
|
||||
skillMasteryContext: {
|
||||
'fiveComplements.4=5-1': { masteryLevel: 'fluent', baseCost: 1, effectiveCost: 2 },
|
||||
'tenComplements.9=10-1': { masteryLevel: 'practicing', baseCost: 2, effectiveCost: 6 },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UI Display
|
||||
|
||||
### Purpose Tooltip (Enhanced)
|
||||
|
||||
The purpose badge tooltip shows complexity information:
|
||||
|
||||
```
|
||||
⭐ Challenge
|
||||
|
||||
Harder problems - every term requires complement techniques.
|
||||
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Complexity │
|
||||
│ ─────────────────────────────────────── │
|
||||
│ Required: ≥1 per term Actual: 2 avg │
|
||||
│ │
|
||||
│ +4 (5-comp) cost: 2 [fluent] │
|
||||
│ +9 (10-comp) cost: 4 [practicing] │
|
||||
│ │
|
||||
│ Total: 6 │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Future Extensions
|
||||
|
||||
### Mastery Recency (Not Implemented Yet)
|
||||
|
||||
The architecture supports adding recency-based mastery states:
|
||||
|
||||
**Scenarios to support:**
|
||||
|
||||
1. **Mastered + continuously practiced** → `effortless` (1×)
|
||||
2. **Mastered + not practiced recently** → `rusty` (2.5×) - NEW STATE
|
||||
3. **Recently mastered** → `fluent` (2×)
|
||||
|
||||
**Implementation path:**
|
||||
|
||||
1. **Track `masteredAt` timestamp** in `player_skill_mastery` table
|
||||
2. **Add `rusty` state** to `MasteryState` type and multipliers:
|
||||
|
||||
```typescript
|
||||
export type MasteryState =
|
||||
| "effortless"
|
||||
| "fluent"
|
||||
| "rusty"
|
||||
| "practicing"
|
||||
| "learning";
|
||||
|
||||
export const MASTERY_MULTIPLIERS: Record<MasteryState, number> = {
|
||||
effortless: 1,
|
||||
fluent: 2,
|
||||
rusty: 2.5, // NEW
|
||||
practicing: 3,
|
||||
learning: 4,
|
||||
};
|
||||
```
|
||||
|
||||
3. **Enhance `dbMasteryToState` conversion:**
|
||||
|
||||
```typescript
|
||||
export function dbMasteryToState(
|
||||
dbLevel: "learning" | "practicing" | "mastered",
|
||||
daysSinceLastPractice?: number,
|
||||
daysSinceMastery?: number,
|
||||
): MasteryState {
|
||||
if (dbLevel === "learning") return "learning";
|
||||
if (dbLevel === "practicing") return "practicing";
|
||||
|
||||
// Mastered - but how rusty?
|
||||
if (daysSinceLastPractice !== undefined && daysSinceLastPractice > 14) {
|
||||
return "rusty"; // Mastered but neglected
|
||||
}
|
||||
if (daysSinceMastery !== undefined && daysSinceMastery > 30) {
|
||||
return "effortless"; // Long-term mastery + recent practice
|
||||
}
|
||||
return "fluent"; // Recently mastered
|
||||
}
|
||||
```
|
||||
|
||||
**Why this is straightforward:**
|
||||
|
||||
- `SkillCostCalculator` is an interface - can swap implementations
|
||||
- `dbMasteryToState` is the single conversion point - all recency logic goes here
|
||||
- `StudentSkillState` interface already has documented extension points
|
||||
- UI captures `skillMasteryContext` in trace - automatically displays new states
|
||||
|
||||
### Other Future Extensions
|
||||
|
||||
1. **Accuracy-based multipliers**: Students with <70% accuracy on a skill get higher multiplier
|
||||
2. **Time-based decay**: Multiplier increases gradually based on days since practice
|
||||
3. **Per-skill complexity overrides**: Some skills are harder for specific students
|
||||
|
||||
## Files Reference
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------------- | ---------------------------------------------- |
|
||||
| `src/utils/skillComplexity.ts` | Base costs, mastery states, calculator factory |
|
||||
| `src/utils/problemGenerator.ts` | Term filtering with budget enforcement |
|
||||
| `src/lib/curriculum/problem-generator.ts` | Wrapper that passes calculator through |
|
||||
| `src/lib/curriculum/session-planner.ts` | Builds calculator, sets purpose bounds |
|
||||
| `src/db/schema/session-plans.ts` | Type definitions, config defaults |
|
||||
| `src/components/practice/ActiveSession.tsx` | UI display of complexity data |
|
||||
|
||||
## Testing
|
||||
|
||||
### Verify Budget Enforcement
|
||||
|
||||
```typescript
|
||||
// Existing test file: src/utils/__tests__/problemGenerator.budget.test.ts
|
||||
|
||||
describe('complexity budget', () => {
|
||||
it('rejects terms exceeding max budget', () => { ... })
|
||||
it('rejects terms below min budget', () => { ... }) // NEW
|
||||
it('uses student mastery to calculate cost', () => { ... })
|
||||
})
|
||||
```
|
||||
|
||||
### Verify UI Display
|
||||
|
||||
Check Storybook stories for `PurposeBadge` with complexity data visible.
|
||||
221
apps/web/.claude/KEHKASHAN_CONSULTATION.md
Normal file
221
apps/web/.claude/KEHKASHAN_CONSULTATION.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Consultation with Kehkashan Khan - Student Learning Model
|
||||
|
||||
## Context
|
||||
|
||||
We are improving the SimulatedStudent model used in journey simulation tests to validate BKT-based adaptive problem generation. The current model uses a Hill function for learning but lacks several realistic phenomena.
|
||||
|
||||
## Current Model Limitations
|
||||
|
||||
| Phenomenon | Reality | Current Model |
|
||||
| -------------------------- | ------------------------------------------ | ---------------------- |
|
||||
| **Forgetting** | Skills decay without practice | Skills never decay |
|
||||
| **Transfer** | Learning one complement helps learn others | Skills are independent |
|
||||
| **Skill difficulty** | Some skills are inherently harder | All skills have same K |
|
||||
| **Within-session fatigue** | Later problems are harder | All problems equal |
|
||||
| **Warm-up effect** | First few problems are shakier | No warm-up |
|
||||
|
||||
## Email Sent to Kehkashan
|
||||
|
||||
**Date:** 2025-12-15
|
||||
**From:** Thomas Hallock <hallock@gmail.com>
|
||||
**To:** Kehkashan Khan
|
||||
**Subject:** (not captured)
|
||||
|
||||
---
|
||||
|
||||
Hi Ms. Hkan,
|
||||
|
||||
I hope you and your mother are doing well in Oman. Please don't feel the need to reply to this immediately—whenever you have a spare moment is fine.
|
||||
|
||||
I've been updating some abacus practice software and I've been testing on Sonia and Fern, but I only have a sample size of 2, so I have had to make some assumptions that I'd like to improve upon. Specifically I've been trying to make it "smarter" about which problems to generate for them. The goal is for the app to automatically detect when they are struggling with a specific movement (like a 5-complement) and give them just enough practice to fix it without getting boring.
|
||||
|
||||
I have a computer simulation running to test this, and have seen some very positive results in learning compared to the method from my books, but I realized my assumptions about how children actually learn might be a bit too simple. Since you have observes this process with many different children, I'd love your take on a few things:
|
||||
|
||||
Are some skills inherently harder? In your experience, are certain movements just naturally harder for kids to grasp than others? For example, is a "10-complement" (like +9 = -1 +10) usually harder to master than a "5-complement" (like +4 = +5 -1)? Or are they about the same difficulty once the concept clicks?
|
||||
|
||||
Do skills transfer? Once a student truly understands the movement for +4, does that make learning +3 easier? Or do they tend to treat every new number as a completely new skill that needs to be practiced from scratch?
|
||||
|
||||
How fast does "rust" set in? If a student masters a specific skill but doesn't use it for two weeks, do they usually retain it? Or do they tend to forget it and need to re-learn it?
|
||||
|
||||
Fatigue vs. Warm-up Do you notice that accuracy drops significantly after 15-20 minutes? Or is there the opposite effect, where they need a "warm-up" period at the start of a lesson before they hit their stride?
|
||||
|
||||
Any "gut feeling" or observations you have would be incredibly helpful. I can use that info to make the math behind the app much more realistic.
|
||||
|
||||
Hope you are managing everything over there. See you Sunday!
|
||||
|
||||
p.s If you're curious, I have written up a draft about the system on my blog here:
|
||||
https://abaci.one/blog/conjunctive-bkt-skill-tracing
|
||||
|
||||
Best,
|
||||
Thomas
|
||||
|
||||
---
|
||||
|
||||
## Questions Asked & How to Use Answers
|
||||
|
||||
### 1. Skill Difficulty
|
||||
|
||||
**Question:** Are 10-complements harder than 5-complements?
|
||||
**How to model:** Add per-skill K values (half-max exposure) in SimulatedStudent
|
||||
|
||||
```typescript
|
||||
const SKILL_DIFFICULTY: Record<string, number> = {
|
||||
"basic.directAddition": 5,
|
||||
"fiveComplements.*": 10, // If she says 5-comp is medium
|
||||
"tenComplements.*": 18, // If she says 10-comp is harder
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Transfer Effects
|
||||
|
||||
**Question:** Does learning +4 help with +3?
|
||||
**How to model:** Add transfer weights between related skills
|
||||
|
||||
```typescript
|
||||
// If she says yes, skills transfer within categories:
|
||||
function getEffectiveExposure(skillId: string): number {
|
||||
const direct = exposures.get(skillId) ?? 0;
|
||||
const transferred = getRelatedSkills(skillId).reduce(
|
||||
(sum, related) => sum + (exposures.get(related) ?? 0) * TRANSFER_WEIGHT,
|
||||
0,
|
||||
);
|
||||
return direct + transferred;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Forgetting/Rust
|
||||
|
||||
**Question:** How fast do skills decay without practice?
|
||||
**How to model:** Multiply probability by retention factor
|
||||
|
||||
```typescript
|
||||
// If she says 2 weeks causes noticeable rust:
|
||||
const HALF_LIFE_DAYS = 14; // Tune based on her answer
|
||||
retention = Math.exp(-daysSinceLastPractice / HALF_LIFE_DAYS);
|
||||
P_effective = P_base * retention;
|
||||
```
|
||||
|
||||
### 4. Fatigue & Warm-up
|
||||
|
||||
**Question:** Does accuracy drop after 15-20 min? Is there warm-up?
|
||||
**How to model:** Add session position effects
|
||||
|
||||
```typescript
|
||||
// If she says both exist:
|
||||
function sessionPositionMultiplier(
|
||||
problemIndex: number,
|
||||
totalProblems: number,
|
||||
): number {
|
||||
const warmupBoost = Math.min(1, problemIndex / 3); // First 3 problems are warm-up
|
||||
const fatiguePenalty = (problemIndex / totalProblems) * 0.1; // 10% drop by end
|
||||
return warmupBoost * (1 - fatiguePenalty);
|
||||
}
|
||||
```
|
||||
|
||||
## Background on Kehkashan
|
||||
|
||||
- Abacus coach for Sonia and Fern (Thomas's kids)
|
||||
- Teaches 1 hour each Sunday
|
||||
- Getting PhD in something related to academic rigor in children
|
||||
- Expert in soroban pedagogy
|
||||
- Currently in Oman caring for her mother
|
||||
- Not deeply technical/statistical, so answers will be qualitative observations
|
||||
|
||||
---
|
||||
|
||||
## Response Received (2025-12-16)
|
||||
|
||||
**From:** Kehkashan Khan
|
||||
|
||||
---
|
||||
|
||||
Hi, good to hear from you. We are taking it one day at a time with my mother. Thank you for asking.
|
||||
|
||||
I appreciate all your concerns about this program.
|
||||
|
||||
First the benefits, it is a developmentally appropriate and age appropriate program. Your books are a bit too complicated if you don't mind me saying that. Your initial push with Sonia and Fern has given them a firm footing. They are such beautiful kids I have no words to describe them.
|
||||
|
||||
My concerns,
|
||||
One is the book I shared with you already. It's unnecessarily complicated.
|
||||
Secondly the abacus itself, if you want them to learn all the skills then they need to use the one that has beads on both sides and should be able to manipulate them using both hands.
|
||||
|
||||
Their foundational skills are strong, maybe you are looking for perfection. I don't know.
|
||||
|
||||
I have seen so much improvement in Fern's mastery of concepts. Sonia was an expert even before I started coaching them. The complicated oral problems she does is amazing.
|
||||
|
||||
Now in general, this is a stressful class, you need to give them more breaks. They are great negotiators, come up with a strategy that will please them but still keep you in control.
|
||||
|
||||
The skills are transferable, not just within the program but also cross curricular. After a while they will want to continue working on this because it makes them smarter and they will know the difference. All the operations whether +/-, combinations of 10 or 5, need practice and patience. Meta cognition is visible all the time, their learning is almost visible.
|
||||
|
||||
Let me see the app , we can arrange a google meet just to check it out. No charges. Children get frustrated when pieces of the puzzle don't fit. I wonder if there are parts that are not quite fitting in their mental framework. I will be able to give you a better idea if I see the components.
|
||||
|
||||
I hope I was able to respond to your questions. I am on break from my university work and can spend some time on your project if required even if it is just for feedback. Also, please leave a google review for my program. It will be greatly appreciated.
|
||||
|
||||
Sincerely,
|
||||
Khan
|
||||
|
||||
---
|
||||
|
||||
## Interpreted Responses (with Thomas's context)
|
||||
|
||||
| Her Statement | Context/Interpretation |
|
||||
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| "Your books are a bit too complicated" | SAI Speed Academy workbooks - Fern needs more repetition than they provide, which drove building the app |
|
||||
| "abacus... beads on both sides... both hands" | Thomas made custom 4-column abaci. Kids will need to transition to full-size after mastering add/subtract |
|
||||
| "this is a stressful class, you need to give them more breaks" | Sunday lessons come after other activities (math, violin). Scheduling issue, not generalizable |
|
||||
| "skills are transferable... cross curricular" | Too general - she means abacus helps general math, not that +4 helps +3 within soroban |
|
||||
| "All operations... need practice and patience" | Every skill needs drilling, none can be skipped. No dramatic difficulty differences implied |
|
||||
| "pieces of the puzzle don't fit" | Validates our goal - she recognizes value of isolating specific deficiencies. Has NOT seen app yet |
|
||||
| "Let me see the app" | Most valuable next step - schedule Google Meet |
|
||||
|
||||
---
|
||||
|
||||
## Follow-up Email Sent (2025-12-16)
|
||||
|
||||
**From:** Thomas Hallock
|
||||
|
||||
---
|
||||
|
||||
Hi Ms. Khan,
|
||||
|
||||
Good to hear from you. I hope you and your mother continue to hold up well.
|
||||
|
||||
Thank you for the feedback on the books and the abacus size. I think you're right that Fern needs more repetition than the books provide, which is what drove me to build the software. I will also look into transitioning them to the full-sized, two-handed abacus now that they are less likely to get distracted by the extra columns.
|
||||
|
||||
I would definitely appreciate a Google Meet. I'd love to walk you through the logic the app uses to diagnose student errors. It attempts to automate the "struggle detection" you do naturally as a teacher, and I could use your feedback on whether it's calibrated correctly.
|
||||
|
||||
You can preview the basic interface at https://abaci.one/practice, but a live demo would be better to explain the background logic.
|
||||
|
||||
Please let me know what time works for you, and send over the link for your Google Review.
|
||||
|
||||
Best,
|
||||
Thomas
|
||||
|
||||
---
|
||||
|
||||
## Implications for Student Model
|
||||
|
||||
### What we learned:
|
||||
|
||||
- **All skills need practice** - No evidence of dramatic difficulty differences between skill categories
|
||||
- **Validation of the goal** - Isolating "puzzle pieces" that don't fit is valuable
|
||||
- **Individual variance** - Sonia vs Fern confirms wide learner differences (matches our profiles)
|
||||
|
||||
### What we still don't know:
|
||||
|
||||
- Whether skills transfer within soroban (does +4 help +3?)
|
||||
- How fast "rust" sets in
|
||||
- Warm-up effects
|
||||
|
||||
### Recommendation:
|
||||
|
||||
Wait for Google Meet feedback before making model changes. She'll provide more specific input after seeing the app's "struggle detection" logic.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Send follow-up email requesting Google Meet
|
||||
2. ⏳ Leave Google review for her program (need link)
|
||||
3. ⏳ Schedule and conduct Google Meet demo
|
||||
4. ⏳ Update this document with her feedback on BKT calibration
|
||||
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
|
||||
154
apps/web/.claude/REMEDIATION_CTA_PLAN.md
Normal file
154
apps/web/.claude/REMEDIATION_CTA_PLAN.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Remediation CTA Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Add special "fancy" treatment to the StartPracticeModal when the student is in remediation mode (has weak skills that need strengthening). This mirrors the existing tutorial CTA treatment.
|
||||
|
||||
## Current Tutorial CTA Treatment (lines 1311-1428)
|
||||
|
||||
When `sessionMode.type === 'progression' && tutorialRequired`:
|
||||
|
||||
1. **Visual Design:**
|
||||
- Green gradient background with border
|
||||
- 🌟 icon
|
||||
- "You've unlocked: [skill name]" heading
|
||||
- "Start with a quick tutorial" subtitle
|
||||
- Green gradient button: "🎓 Begin Tutorial →"
|
||||
|
||||
2. **Behavior:**
|
||||
- Replaces the regular "Let's Go!" button
|
||||
- Clicking opens the SkillTutorialLauncher
|
||||
|
||||
## Proposed Remediation CTA
|
||||
|
||||
When `sessionMode.type === 'remediation'`:
|
||||
|
||||
1. **Visual Design:**
|
||||
- Amber/orange gradient background with border (warm "focus" colors)
|
||||
- 💪 icon (strength/building)
|
||||
- "Time to build strength!" heading
|
||||
- "Focusing on [N] skills that need practice" subtitle
|
||||
- Show weak skill badges with pKnown percentages
|
||||
- Amber gradient button: "💪 Start Focus Practice →"
|
||||
|
||||
2. **Behavior:**
|
||||
- Replaces the regular "Let's Go!" button
|
||||
- Clicking goes straight to practice (no separate launcher needed)
|
||||
- The session will automatically target weak skills via sessionMode
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add remediation detection
|
||||
|
||||
```typescript
|
||||
// Derive whether to show remediation CTA
|
||||
const showRemediationCta =
|
||||
sessionMode.type === "remediation" && sessionMode.weakSkills.length > 0;
|
||||
```
|
||||
|
||||
### Step 2: Create RemediationCta component section
|
||||
|
||||
Add after the Tutorial CTA section (line ~1428), or restructure to have a single "special CTA" section that handles both cases.
|
||||
|
||||
```tsx
|
||||
{/* Remediation CTA - Weak skills need strengthening */}
|
||||
{showRemediationCta && !showTutorialGate && (
|
||||
<div
|
||||
data-element="remediation-cta"
|
||||
className={css({...})}
|
||||
style={{
|
||||
background: isDark
|
||||
? 'linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(234, 88, 12, 0.08) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(234, 88, 12, 0.05) 100%)',
|
||||
border: `2px solid ${isDark ? 'rgba(245, 158, 11, 0.25)' : 'rgba(245, 158, 11, 0.2)'}`,
|
||||
}}
|
||||
>
|
||||
{/* Info section */}
|
||||
<div className={css({...})}>
|
||||
<span>💪</span>
|
||||
<div>
|
||||
<p>Time to build strength!</p>
|
||||
<p>Focusing on {weakSkills.length} skill{weakSkills.length > 1 ? 's' : ''} that need practice</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weak skills badges */}
|
||||
<div className={css({...})}>
|
||||
{sessionMode.weakSkills.slice(0, 4).map((skill) => (
|
||||
<span key={skill.skillId} className={css({...})}>
|
||||
{skill.displayName} ({Math.round(skill.pKnown * 100)}%)
|
||||
</span>
|
||||
))}
|
||||
{sessionMode.weakSkills.length > 4 && (
|
||||
<span>+{sessionMode.weakSkills.length - 4} more</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Integrated start button */}
|
||||
<button
|
||||
data-action="start-focus-practice"
|
||||
onClick={handleStart}
|
||||
disabled={isStarting}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
}}
|
||||
>
|
||||
{isStarting ? 'Starting...' : (
|
||||
<>
|
||||
<span>💪</span>
|
||||
<span>Start Focus Practice</span>
|
||||
<span>→</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### Step 3: Update start button visibility logic
|
||||
|
||||
Change from:
|
||||
|
||||
```tsx
|
||||
{
|
||||
!showTutorialGate && <button>Let's Go! →</button>;
|
||||
}
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```tsx
|
||||
{
|
||||
!showTutorialGate && !showRemediationCta && <button>Let's Go! →</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## Visual Comparison
|
||||
|
||||
| Mode | Icon | Color Theme | Heading | Button Text |
|
||||
| ----------- | ---- | ----------- | -------------------------- | --------------------------- |
|
||||
| Tutorial | 🌟 | Green | "You've unlocked: [skill]" | "🎓 Begin Tutorial →" |
|
||||
| Remediation | 💪 | Amber | "Time to build strength!" | "💪 Start Focus Practice →" |
|
||||
| Normal | - | Blue | "Ready to practice?" | "Let's Go! →" |
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `apps/web/src/components/practice/StartPracticeModal.tsx`
|
||||
- Add `showRemediationCta` derived state
|
||||
- Add Remediation CTA section (similar structure to Tutorial CTA)
|
||||
- Update regular start button visibility condition
|
||||
|
||||
## Testing Considerations
|
||||
|
||||
1. Storybook stories should cover:
|
||||
- Remediation mode with 1 weak skill
|
||||
- Remediation mode with 3+ weak skills
|
||||
- Remediation mode with 5+ weak skills (overflow)
|
||||
|
||||
2. The existing `StartPracticeModal.stories.tsx` already has sessionMode mocks - add remediation variants.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Ensure proper ARIA labels on the remediation CTA
|
||||
- Color contrast should meet WCAG guidelines (amber text on amber background needs checking)
|
||||
- Screen reader should announce the focus practice intent
|
||||
171
apps/web/.claude/SESSION_MODE_PLAN.md
Normal file
171
apps/web/.claude/SESSION_MODE_PLAN.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Session Mode Unified Architecture
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current architecture has three independent BKT computations:
|
||||
|
||||
1. Dashboard computes BKT locally for skill cards
|
||||
2. Modal computes BKT locally for "Targeting: X" preview
|
||||
3. Session planner computes BKT when generating problems
|
||||
|
||||
This creates potential mismatches where the modal shows one thing but the session planner does another ("rug-pulling").
|
||||
|
||||
Additionally, students see conflicting signals:
|
||||
|
||||
- Header: "Addition: +1 (Direct Method)"
|
||||
- Tutorial notice: "You've unlocked: +1 = +5 - 4"
|
||||
- Targeting: "+3 = +5 - 2"
|
||||
|
||||
## Solution: Unified SessionMode
|
||||
|
||||
A single `SessionMode` object computed once and used everywhere:
|
||||
|
||||
- Dashboard (what banner to show)
|
||||
- Modal (what CTA to display)
|
||||
- Session planner (what problems to generate)
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **No rug-pulling**: Whatever the modal shows IS what configures problem generation
|
||||
2. **Transparent blocking**: When remediation blocks promotion, student knows why
|
||||
3. **Single source of truth**: One computation, used everywhere
|
||||
|
||||
## SessionMode Type Definition
|
||||
|
||||
```typescript
|
||||
interface SkillInfo {
|
||||
skillId: string;
|
||||
displayName: string;
|
||||
pKnown: number; // 0-1 probability
|
||||
}
|
||||
|
||||
type SessionMode =
|
||||
| {
|
||||
type: "remediation";
|
||||
weakSkills: SkillInfo[];
|
||||
focusDescription: string;
|
||||
// What promotion is being blocked
|
||||
blockedPromotion?: {
|
||||
nextSkill: SkillInfo;
|
||||
reason: string; // "Strengthen +3 and +5-2 first"
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "progression";
|
||||
nextSkill: SkillInfo;
|
||||
tutorialRequired: boolean;
|
||||
focusDescription: string;
|
||||
}
|
||||
| {
|
||||
type: "maintenance";
|
||||
focusDescription: string; // "All skills strong - mixed practice"
|
||||
};
|
||||
```
|
||||
|
||||
## UI States
|
||||
|
||||
### Dashboard Banner Area
|
||||
|
||||
**Progression Mode:**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 🌟 New Skill Unlocked! │
|
||||
│ You're ready to learn: +5 - 4 │
|
||||
│ [Start Practice] │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Remediation Mode (with blocked promotion):**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 🔒 Almost there! │
|
||||
│ Strengthen +3 and +5-2 to unlock: +5 - 4 │
|
||||
│ Progress: ████████░░ 80% │
|
||||
│ [Practice Now] │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Maintenance Mode:**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ ✨ All skills strong! │
|
||||
│ Keep practicing to maintain mastery │
|
||||
│ [Practice] │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Modal CTA Area
|
||||
|
||||
**Progression Mode:**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 🌟 You've unlocked: +5 - 4 │
|
||||
│ Start with a quick tutorial │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🎓 Begin Tutorial → │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Remediation Mode:**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 💪 Strengthening weak skills │
|
||||
│ Targeting: +3, +5-2 │
|
||||
│ Then you'll unlock: +5 - 4 │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ Let's Go! → │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
1. Dashboard loads → GET /api/curriculum/{playerId}/session-mode
|
||||
→ Returns SessionMode (computed once)
|
||||
→ Dashboard displays appropriate banner
|
||||
|
||||
2. User clicks "Start Practice" → Modal opens
|
||||
→ Modal receives SAME SessionMode
|
||||
→ Displays matching CTA
|
||||
|
||||
3. User clicks "Let's Go!" → generateSessionPlan(sessionMode)
|
||||
→ Session planner uses the SAME mode
|
||||
→ Problems generated match what modal showed
|
||||
```
|
||||
|
||||
## Implementation Files
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/lib/curriculum/session-mode.ts` - Core `getSessionMode()` function
|
||||
- `src/hooks/useSessionMode.ts` - React Query hook
|
||||
- `src/app/api/curriculum/[playerId]/session-mode/route.ts` - API endpoint
|
||||
- `src/components/practice/SessionModeBanner.tsx` - Unified banner component
|
||||
- `src/stories/SessionModeBanner.stories.tsx` - Storybook stories
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `src/components/practice/StartPracticeModal.tsx` - Use SessionMode instead of local BKT
|
||||
- `src/app/practice/[studentId]/dashboard/DashboardClient.tsx` - Use SessionModeBanner
|
||||
- `src/lib/curriculum/session-planner.ts` - Accept SessionMode as input
|
||||
- `src/hooks/useNextSkillToLearn.ts` - Deprecate or derive from useSessionMode
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Create `SessionMode` types and `getSessionMode()` function
|
||||
2. Create API endpoint
|
||||
3. Create `useSessionMode()` hook
|
||||
4. Create `SessionModeBanner` component with all 3 modes
|
||||
5. Add Storybook stories for all states
|
||||
6. Update Dashboard to use new banner
|
||||
7. Update Modal to use SessionMode
|
||||
8. Update session planner to accept SessionMode
|
||||
9. Remove duplicate BKT computations
|
||||
10. Test end-to-end flow
|
||||
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
179
apps/web/.claude/SIMULATED_STUDENT_MODEL.md
Normal file
179
apps/web/.claude/SIMULATED_STUDENT_MODEL.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Simulated Student Model
|
||||
|
||||
## Overview
|
||||
|
||||
The `SimulatedStudent` class models how students learn soroban skills over time. It's used in journey simulation tests to validate that BKT-based adaptive problem generation outperforms classic random generation.
|
||||
|
||||
**Location:** `src/test/journey-simulator/SimulatedStudent.ts`
|
||||
|
||||
## Core Model: Hill Function Learning
|
||||
|
||||
The model uses the **Hill function** (from biochemistry/pharmacology) to model learning:
|
||||
|
||||
```
|
||||
P(correct | skill) = exposure^n / (K^n + exposure^n)
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
- **exposure**: Number of times the student has attempted problems using this skill
|
||||
- **K** (halfMaxExposure): Exposure count where P(correct) = 0.5
|
||||
- **n** (hillCoefficient): Controls curve shape (n > 1 delays onset, then accelerates)
|
||||
|
||||
### Why Hill Function?
|
||||
|
||||
The Hill function naturally models how real learning works:
|
||||
|
||||
1. **Early struggles**: Low exposure = low probability (building foundation)
|
||||
2. **Breakthrough**: At some point, understanding "clicks" (steep improvement)
|
||||
3. **Mastery plateau**: High exposure approaches but never reaches 100%
|
||||
|
||||
### Example Curves
|
||||
|
||||
With K=10, n=2:
|
||||
|
||||
| Exposures | P(correct) | Stage |
|
||||
| --------- | ---------- | ----------------------------- |
|
||||
| 0 | 0% | No knowledge |
|
||||
| 5 | 20% | Building foundation |
|
||||
| 10 | 50% | Half-way (by definition of K) |
|
||||
| 15 | 69% | Understanding clicks |
|
||||
| 20 | 80% | Confident |
|
||||
| 30 | 90% | Near mastery |
|
||||
|
||||
## Skill-Specific Difficulty
|
||||
|
||||
**Key insight from pedagogy:** Not all skills are equally hard. Ten-complements require cross-column operations and are inherently harder than five-complements.
|
||||
|
||||
### Difficulty Multipliers
|
||||
|
||||
Each skill has a difficulty multiplier applied to K:
|
||||
|
||||
```typescript
|
||||
effectiveK = profile.halfMaxExposure * SKILL_DIFFICULTY_MULTIPLIER[skillId];
|
||||
```
|
||||
|
||||
| Skill Category | Multiplier | Effect |
|
||||
| ---------------------------------- | ---------- | -------------------------------- |
|
||||
| Basic (directAddition, heavenBead) | 0.8-0.9x | Easier, fewer exposures needed |
|
||||
| Five-complements | 1.2-1.3x | Moderate, ~20-30% more exposures |
|
||||
| Ten-complements | 1.6-2.1x | Hardest, ~60-110% more exposures |
|
||||
|
||||
### Concrete Example
|
||||
|
||||
With profile K=10:
|
||||
|
||||
| Skill | Multiplier | Effective K | Exposures for 50% |
|
||||
| --------------------- | ---------- | ----------- | ----------------- |
|
||||
| basic.directAddition | 0.8 | 8 | 8 |
|
||||
| fiveComplements.4=5-1 | 1.2 | 12 | 12 |
|
||||
| tenComplements.9=10-1 | 1.6 | 16 | 16 |
|
||||
| tenComplements.1=10-9 | 2.0 | 20 | 20 |
|
||||
|
||||
### Rationale for Specific Values
|
||||
|
||||
Based on soroban pedagogy:
|
||||
|
||||
- **Basic skills (0.8-0.9)**: Single-column, direct bead manipulation
|
||||
- **Five-complements (1.2-1.3)**: Requires decomposition thinking (+4 = +5 -1)
|
||||
- **Ten-complements (1.6-2.1)**: Cross-column carrying/borrowing, harder mental model
|
||||
- **Harder ten-complements**: Larger adjustments (tenComplements.1=10-9 = +1 requires -9+10) are cognitively harder
|
||||
|
||||
## Conjunctive Model for Multi-Skill Problems
|
||||
|
||||
When a problem requires multiple skills (e.g., basic.directAddition + tenComplements.9=10-1):
|
||||
|
||||
```
|
||||
P(correct) = P(skill_A) × P(skill_B) × P(skill_C) × ...
|
||||
```
|
||||
|
||||
This models that ALL component skills must be applied correctly. A student strong in basics but weak in ten-complements will fail problems requiring ten-complements.
|
||||
|
||||
## Student Profiles
|
||||
|
||||
Profiles define different learner types:
|
||||
|
||||
```typescript
|
||||
interface StudentProfile {
|
||||
name: string;
|
||||
halfMaxExposure: number; // K: lower = faster learner
|
||||
hillCoefficient: number; // n: curve shape
|
||||
initialExposures: Record<string, number>; // Pre-seeded learning
|
||||
helpUsageProbabilities: [number, number, number, number];
|
||||
helpBonuses: [number, number, number, number];
|
||||
baseResponseTimeMs: number;
|
||||
responseTimeVariance: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Example Profiles
|
||||
|
||||
| Profile | K | n | Description |
|
||||
| --------------- | --- | --- | ---------------------------------- |
|
||||
| Fast Learner | 8 | 1.5 | Quick acquisition, smooth curve |
|
||||
| Average Learner | 12 | 2.0 | Typical learning rate |
|
||||
| Slow Learner | 15 | 2.5 | Needs more practice, delayed onset |
|
||||
|
||||
## Exposure Accumulation
|
||||
|
||||
**Critical behavior**: Exposure increments on EVERY attempt, not just correct answers.
|
||||
|
||||
This models that students learn from engaging with material, regardless of success. The attempt itself is the learning event.
|
||||
|
||||
```typescript
|
||||
// Learning happens from attempting, not just succeeding
|
||||
for (const skillId of skillsChallenged) {
|
||||
const current = this.skillExposures.get(skillId) ?? 0;
|
||||
this.skillExposures.set(skillId, current + 1);
|
||||
}
|
||||
```
|
||||
|
||||
## Fatigue Tracking
|
||||
|
||||
The model tracks cognitive load based on true skill mastery:
|
||||
|
||||
| True P(correct) | Fatigue Multiplier | Interpretation |
|
||||
| --------------- | ------------------ | ------------------------------ |
|
||||
| ≥ 90% | 1.0x | Automated, low effort |
|
||||
| ≥ 70% | 1.5x | Nearly automated |
|
||||
| ≥ 50% | 2.0x | Moderate effort |
|
||||
| ≥ 30% | 3.0x | Struggling |
|
||||
| < 30% | 4.0x | Very weak, high cognitive load |
|
||||
|
||||
## Help System
|
||||
|
||||
Students can use help at four levels:
|
||||
|
||||
- **Level 0**: No help
|
||||
- **Level 1**: Hint
|
||||
- **Level 2**: Decomposition shown
|
||||
- **Level 3**: Full solution
|
||||
|
||||
Help provides an additive bonus to probability (not multiplicative), simulating that help scaffolds understanding but doesn't guarantee correctness.
|
||||
|
||||
## Validation
|
||||
|
||||
The model is validated by:
|
||||
|
||||
1. **BKT Correlation**: BKT's P(known) should correlate with true P(correct)
|
||||
2. **Learning Trajectories**: Accuracy should improve over sessions
|
||||
3. **Skill Targeting**: Adaptive mode should surface weak skills faster
|
||||
4. **Difficulty Ordering**: Ten-complements should take longer to master than five-complements
|
||||
|
||||
## Files
|
||||
|
||||
- `src/test/journey-simulator/SimulatedStudent.ts` - Main model implementation
|
||||
- `src/test/journey-simulator/types.ts` - StudentProfile type definition
|
||||
- `src/test/journey-simulator/profiles/` - Predefined learner profiles
|
||||
- `src/test/journey-simulator/journey-simulator.test.ts` - Validation tests
|
||||
|
||||
## Future Improvements
|
||||
|
||||
Based on consultation with Kehkashan Khan (abacus coach):
|
||||
|
||||
1. **Forgetting/Decay**: Skills may decay without practice (not yet implemented)
|
||||
2. **Transfer Effects**: Learning +4 may help learning +3 (not yet implemented)
|
||||
3. **Warm-up Effects**: First few problems may be shakier (not yet implemented)
|
||||
4. **Within-session Fatigue**: Later problems may be harder (partially implemented via fatigue tracking)
|
||||
|
||||
See `.claude/KEHKASHAN_CONSULTATION.md` for full consultation notes.
|
||||
810
apps/web/.claude/SKILL_TUTORIAL_INTEGRATION_PLAN.md
Normal file
810
apps/web/.claude/SKILL_TUTORIAL_INTEGRATION_PLAN.md
Normal file
@@ -0,0 +1,810 @@
|
||||
# Skill Tutorial Integration Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the integration between the curriculum skill system and the existing tutorial system to create a **tutorial-gated skill progression** with **gap-filling enforcement**.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Skills have two states:**
|
||||
- **Conceptual understanding** (tutorial completed) - "I understand how this works"
|
||||
- **Fluency** (practice mastery) - "I can do this automatically under cognitive load"
|
||||
|
||||
2. **Tutorial completion is required before practice:**
|
||||
- A skill must have tutorial completion BEFORE it enters practice rotation (`isPracticing=true`)
|
||||
- Teacher override is available for offline learning scenarios
|
||||
|
||||
3. **Gap-filling is strict:**
|
||||
- Cannot advance to higher curriculum phases until ALL prerequisite skills are mastered
|
||||
- System identifies gaps and prioritizes them over new skill introduction
|
||||
|
||||
---
|
||||
|
||||
## The Tutorial System (Already Exists)
|
||||
|
||||
### `generateUnifiedInstructionSequence(startValue, targetValue)`
|
||||
|
||||
Location: `src/utils/unifiedStepGenerator.ts`
|
||||
|
||||
This function is a complete pedagogical engine that:
|
||||
|
||||
- Takes any `(startValue, targetValue)` pair
|
||||
- Generates step-by-step bead movements with English instructions
|
||||
- Detects which complement rules are used (Direct, FiveComplement, TenComplement, Cascade)
|
||||
- Creates `PedagogicalSegment` objects with human-readable explanations
|
||||
|
||||
**Output structure:**
|
||||
|
||||
```typescript
|
||||
interface UnifiedInstructionSequence {
|
||||
fullDecomposition: string; // e.g., "3 + 4 = 3 + (5 - 1) = 7"
|
||||
isMeaningfulDecomposition: boolean;
|
||||
steps: UnifiedStepData[]; // Each step has:
|
||||
// - mathematicalTerm: "5", "-1"
|
||||
// - englishInstruction: "activate heaven bead", "remove 1 earth bead"
|
||||
// - expectedValue: number after this step
|
||||
// - expectedState: AbacusState after this step
|
||||
// - beadMovements: which beads to move
|
||||
segments: PedagogicalSegment[]; // High-level explanations:
|
||||
// - readable.title: "Make 5 — ones"
|
||||
// - readable.summary: "Add 4 to the ones, but there isn't room..."
|
||||
// - readable.subtitle: "Using 5's friend"
|
||||
}
|
||||
```
|
||||
|
||||
### TutorialPlayer Component
|
||||
|
||||
Location: `src/components/tutorial/TutorialPlayer.tsx`
|
||||
|
||||
Already handles:
|
||||
|
||||
- Step-by-step guided practice
|
||||
- Bead highlighting and movement tracking
|
||||
- Progress tracking through steps
|
||||
- "Next step" / "Try again" interaction
|
||||
|
||||
---
|
||||
|
||||
## Integration Architecture
|
||||
|
||||
### Key Insight: Generate Tutorials Dynamically
|
||||
|
||||
Instead of authoring tutorials for each of 30+ skills, we **generate tutorials dynamically** by:
|
||||
|
||||
1. **For a given skill**, identify example problems that REQUIRE that skill
|
||||
2. **Generate tutorial steps** using `generateUnifiedInstructionSequence()`
|
||||
3. **Present using TutorialPlayer** with auto-generated steps
|
||||
|
||||
### Skill → Tutorial Problem Mapping
|
||||
|
||||
Each skill maps to a set of example problems that demonstrate it:
|
||||
|
||||
```typescript
|
||||
// src/lib/curriculum/skill-tutorial-config.ts
|
||||
|
||||
interface SkillTutorialConfig {
|
||||
skillId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Example problems that demonstrate this skill */
|
||||
exampleProblems: Array<{ start: number; target: number }>;
|
||||
/** Number of practice problems before sign-off (default 3) */
|
||||
practiceCount?: number;
|
||||
}
|
||||
|
||||
export const SKILL_TUTORIAL_CONFIGS: Record<string, SkillTutorialConfig> = {
|
||||
// Five-complement addition
|
||||
"fiveComplements.4=5-1": {
|
||||
skillId: "fiveComplements.4=5-1",
|
||||
title: "Adding 4 using 5's friend",
|
||||
description:
|
||||
"When you need to add 4 but don't have room for 4 earth beads, use 5's friend: add 5, then take away 1.",
|
||||
exampleProblems: [
|
||||
{ start: 1, target: 5 }, // 1 + 4 = 5 (simplest)
|
||||
{ start: 2, target: 6 }, // 2 + 4 = 6
|
||||
{ start: 3, target: 7 }, // 3 + 4 = 7
|
||||
],
|
||||
practiceCount: 3,
|
||||
},
|
||||
|
||||
"fiveComplements.3=5-2": {
|
||||
skillId: "fiveComplements.3=5-2",
|
||||
title: "Adding 3 using 5's friend",
|
||||
description:
|
||||
"When you need to add 3 but don't have room, use 5's friend: add 5, then take away 2.",
|
||||
exampleProblems: [
|
||||
{ start: 2, target: 5 },
|
||||
{ start: 3, target: 6 },
|
||||
{ start: 4, target: 7 },
|
||||
],
|
||||
},
|
||||
|
||||
// Ten-complement addition
|
||||
"tenComplements.9=10-1": {
|
||||
skillId: "tenComplements.9=10-1",
|
||||
title: "Adding 9 with a carry",
|
||||
description:
|
||||
"When adding 9 would overflow the column, carry 10 to the next column and take away 1 here.",
|
||||
exampleProblems: [
|
||||
{ start: 1, target: 10 }, // 1 + 9 = 10
|
||||
{ start: 2, target: 11 }, // 2 + 9 = 11
|
||||
{ start: 5, target: 14 }, // 5 + 9 = 14
|
||||
],
|
||||
},
|
||||
|
||||
// Five-complement subtraction
|
||||
"fiveComplementsSub.-4=-5+1": {
|
||||
skillId: "fiveComplementsSub.-4=-5+1",
|
||||
title: "Subtracting 4 using 5's friend",
|
||||
description:
|
||||
"When you need to subtract 4 but don't have 4 earth beads, use 5's friend: take away 5, then add 1 back.",
|
||||
exampleProblems: [
|
||||
{ start: 5, target: 1 },
|
||||
{ start: 6, target: 2 },
|
||||
{ start: 7, target: 3 },
|
||||
],
|
||||
},
|
||||
|
||||
// Ten-complement subtraction
|
||||
"tenComplementsSub.-9=+1-10": {
|
||||
skillId: "tenComplementsSub.-9=+1-10",
|
||||
title: "Subtracting 9 with a borrow",
|
||||
description:
|
||||
"When subtracting 9 but you don't have enough, borrow 10 from the next column and add 1 here.",
|
||||
exampleProblems: [
|
||||
{ start: 10, target: 1 },
|
||||
{ start: 11, target: 2 },
|
||||
{ start: 15, target: 6 },
|
||||
],
|
||||
},
|
||||
|
||||
// Basic skills (simpler tutorials)
|
||||
"basic.directAddition": {
|
||||
skillId: "basic.directAddition",
|
||||
title: "Adding by moving earth beads",
|
||||
description:
|
||||
"The simplest way to add: just push up the earth beads you need.",
|
||||
exampleProblems: [
|
||||
{ start: 0, target: 1 },
|
||||
{ start: 0, target: 3 },
|
||||
{ start: 1, target: 4 },
|
||||
],
|
||||
},
|
||||
|
||||
"basic.heavenBead": {
|
||||
skillId: "basic.heavenBead",
|
||||
title: "Using the heaven bead for 5",
|
||||
description:
|
||||
"The heaven bead is worth 5. Push it down to add 5 in one move.",
|
||||
exampleProblems: [
|
||||
{ start: 0, target: 5 },
|
||||
{ start: 1, target: 6 },
|
||||
{ start: 3, target: 8 },
|
||||
],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New Data Model
|
||||
|
||||
### skill_tutorial_progress Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE skill_tutorial_progress (
|
||||
id TEXT PRIMARY KEY,
|
||||
player_id TEXT NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
skill_id TEXT NOT NULL,
|
||||
|
||||
-- Tutorial completion state
|
||||
tutorial_completed INTEGER NOT NULL DEFAULT 0, -- boolean
|
||||
completed_at INTEGER, -- timestamp
|
||||
|
||||
-- Teacher override
|
||||
teacher_override INTEGER NOT NULL DEFAULT 0, -- boolean
|
||||
override_at INTEGER,
|
||||
override_reason TEXT, -- e.g., "Learned in class with Kehkashan"
|
||||
|
||||
-- Metadata
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
|
||||
UNIQUE(player_id, skill_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_skill_tutorial_player ON skill_tutorial_progress(player_id);
|
||||
```
|
||||
|
||||
### Schema Definition
|
||||
|
||||
```typescript
|
||||
// src/db/schema/skill-tutorial-progress.ts
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
sqliteTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { players } from "./players";
|
||||
|
||||
export const skillTutorialProgress = sqliteTable(
|
||||
"skill_tutorial_progress",
|
||||
{
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => createId()),
|
||||
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
|
||||
skillId: text("skill_id").notNull(),
|
||||
|
||||
// Tutorial completion
|
||||
tutorialCompleted: integer("tutorial_completed", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
completedAt: integer("completed_at", { mode: "timestamp" }),
|
||||
|
||||
// Teacher override (bypasses tutorial requirement)
|
||||
teacherOverride: integer("teacher_override", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
overrideAt: integer("override_at", { mode: "timestamp" }),
|
||||
overrideReason: text("override_reason"),
|
||||
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
},
|
||||
(table) => ({
|
||||
playerIdIdx: index("skill_tutorial_player_idx").on(table.playerId),
|
||||
playerSkillUnique: uniqueIndex("skill_tutorial_player_skill_unique").on(
|
||||
table.playerId,
|
||||
table.skillId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Skill Algorithm
|
||||
|
||||
Simple linear walk through curriculum: find the **first unmastered, unpracticed skill**.
|
||||
|
||||
### `getNextSkillToLearn(playerId)`
|
||||
|
||||
```typescript
|
||||
// src/lib/curriculum/skill-unlock.ts
|
||||
|
||||
interface SkillSuggestion {
|
||||
skillId: string;
|
||||
phaseId: string;
|
||||
phaseName: string;
|
||||
description: string;
|
||||
/** True if tutorial is already completed (or teacher override) */
|
||||
tutorialReady: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next skill the student should learn.
|
||||
*
|
||||
* Algorithm: Walk through curriculum phases in order.
|
||||
* - If skill is MASTERED → skip (they know it)
|
||||
* - If skill is PRACTICING → return null (they're working on it)
|
||||
* - Otherwise → this is the next skill to learn
|
||||
*/
|
||||
export async function getNextSkillToLearn(
|
||||
playerId: string,
|
||||
): Promise<SkillSuggestion | null> {
|
||||
// 1. Get mastered skills from BKT
|
||||
const history = await getRecentSessionResults(playerId, 100);
|
||||
const bktResults = computeBktFromHistory(history, {
|
||||
confidenceThreshold: 0.3,
|
||||
useCrossStudentPriors: false,
|
||||
});
|
||||
const masteredSkillIds = new Set(
|
||||
bktResults.skills
|
||||
.filter((s) => s.masteryClassification === "mastered")
|
||||
.map((s) => s.skillId),
|
||||
);
|
||||
|
||||
// 2. Get currently practicing skills
|
||||
const practicing = await getPracticingSkills(playerId);
|
||||
const practicingIds = new Set(practicing.map((s) => s.skillId));
|
||||
|
||||
// 3. Walk curriculum in order
|
||||
for (const phase of ALL_PHASES) {
|
||||
const skillId = phase.primarySkillId;
|
||||
|
||||
// Mastered? Skip - they know it
|
||||
if (masteredSkillIds.has(skillId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Currently practicing? They're working on it - no new suggestion
|
||||
if (practicingIds.has(skillId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Found first unmastered, unpracticed skill!
|
||||
const tutorialProgress = await getSkillTutorialProgress(playerId, skillId);
|
||||
const tutorialReady =
|
||||
tutorialProgress?.tutorialCompleted ||
|
||||
tutorialProgress?.teacherOverride ||
|
||||
false;
|
||||
|
||||
return {
|
||||
skillId,
|
||||
phaseId: phase.id,
|
||||
phaseName: phase.name,
|
||||
description: phase.description,
|
||||
tutorialReady,
|
||||
};
|
||||
}
|
||||
|
||||
// All phases complete - curriculum finished!
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get anomalies for teacher dashboard.
|
||||
* Returns skills that are mastered but not in practice rotation.
|
||||
*/
|
||||
export async function getSkillAnomalies(playerId: string): Promise<
|
||||
Array<{
|
||||
skillId: string;
|
||||
issue: "mastered_not_practicing" | "tutorial_skipped_repeatedly";
|
||||
details: string;
|
||||
}>
|
||||
> {
|
||||
const anomalies = [];
|
||||
|
||||
// Get mastered and practicing sets
|
||||
const history = await getRecentSessionResults(playerId, 100);
|
||||
const bktResults = computeBktFromHistory(history, {
|
||||
confidenceThreshold: 0.3,
|
||||
});
|
||||
const masteredSkillIds = new Set(
|
||||
bktResults.skills
|
||||
.filter((s) => s.masteryClassification === "mastered")
|
||||
.map((s) => s.skillId),
|
||||
);
|
||||
|
||||
const practicing = await getPracticingSkills(playerId);
|
||||
const practicingIds = new Set(practicing.map((s) => s.skillId));
|
||||
|
||||
// Find mastered but not practicing
|
||||
for (const skillId of masteredSkillIds) {
|
||||
if (!practicingIds.has(skillId)) {
|
||||
anomalies.push({
|
||||
skillId,
|
||||
issue: "mastered_not_practicing" as const,
|
||||
details: "Skill is mastered but not in practice rotation",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Track tutorial skip count and flag repeated skips
|
||||
|
||||
return anomalies;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tutorial Launcher Component
|
||||
|
||||
### SkillTutorialLauncher
|
||||
|
||||
```typescript
|
||||
// src/components/tutorial/SkillTutorialLauncher.tsx
|
||||
|
||||
interface SkillTutorialLauncherProps {
|
||||
skillId: string
|
||||
playerId: string
|
||||
onComplete: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function SkillTutorialLauncher({
|
||||
skillId,
|
||||
playerId,
|
||||
onComplete,
|
||||
onCancel,
|
||||
}: SkillTutorialLauncherProps) {
|
||||
const config = SKILL_TUTORIAL_CONFIGS[skillId]
|
||||
|
||||
if (!config) {
|
||||
return <div>No tutorial available for {skillId}</div>
|
||||
}
|
||||
|
||||
// Generate tutorial from config
|
||||
const [currentProblemIndex, setCurrentProblemIndex] = useState(0)
|
||||
const currentProblem = config.exampleProblems[currentProblemIndex]
|
||||
|
||||
// Generate instruction sequence for current problem
|
||||
const sequence = useMemo(() => {
|
||||
return generateUnifiedInstructionSequence(
|
||||
currentProblem.start,
|
||||
currentProblem.target
|
||||
)
|
||||
}, [currentProblem])
|
||||
|
||||
// Convert to tutorial steps
|
||||
const tutorialSteps = useMemo(() => {
|
||||
return sequence.steps.map((step, i) => ({
|
||||
instruction: step.englishInstruction,
|
||||
expectedValue: step.expectedValue,
|
||||
expectedState: step.expectedState,
|
||||
beadHighlights: step.beadMovements,
|
||||
segment: sequence.segments.find(s => s.stepIndices.includes(i)),
|
||||
}))
|
||||
}, [sequence])
|
||||
|
||||
const handleProblemComplete = async () => {
|
||||
if (currentProblemIndex < config.exampleProblems.length - 1) {
|
||||
// More problems to go
|
||||
setCurrentProblemIndex(i => i + 1)
|
||||
} else {
|
||||
// Tutorial complete!
|
||||
await markTutorialComplete(playerId, skillId)
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="skill-tutorial-launcher">
|
||||
{/* Header with skill info */}
|
||||
<header>
|
||||
<h2>{config.title}</h2>
|
||||
<p>{config.description}</p>
|
||||
<div>
|
||||
Problem {currentProblemIndex + 1} of {config.exampleProblems.length}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Show the decomposition */}
|
||||
<div data-section="decomposition">
|
||||
<code>{sequence.fullDecomposition}</code>
|
||||
</div>
|
||||
|
||||
{/* Show segment explanation if meaningful */}
|
||||
{sequence.segments[0]?.readable && (
|
||||
<div data-section="explanation">
|
||||
<h3>{sequence.segments[0].readable.title}</h3>
|
||||
<p>{sequence.segments[0].readable.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interactive tutorial player */}
|
||||
<TutorialPlayer
|
||||
steps={tutorialSteps}
|
||||
startValue={currentProblem.start}
|
||||
targetValue={currentProblem.target}
|
||||
onComplete={handleProblemComplete}
|
||||
/>
|
||||
|
||||
{/* Cancel button */}
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Integration Points
|
||||
|
||||
### Primary Gate: Start Practice Modal
|
||||
|
||||
The tutorial happens BEFORE practice, not after. When a student sits down to practice,
|
||||
that's when they learn the new skill - not when they're done and tired.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ STUDENT CLICKS "START PRACTICE" │
|
||||
│ ↓ │
|
||||
│ │
|
||||
│ CHECK: Is there a new skill ready to learn? │
|
||||
│ (first unmastered, unpracticed skill in curriculum) │
|
||||
│ AND tutorial not yet completed? │
|
||||
│ │
|
||||
│ ↓ ↓ │
|
||||
│ YES NO │
|
||||
│ ↓ ↓ │
|
||||
│ │
|
||||
│ START PRACTICE MODAL START PRACTICE MODAL │
|
||||
│ ┌─────────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Before we practice, │ │ Ready to practice? │ │
|
||||
│ │ let's learn something │ │ │ │
|
||||
│ │ new! │ │ [Start Session] │ │
|
||||
│ │ │ └─────────────────────┘ │
|
||||
│ │ +3 Five-Complement │ ↓ │
|
||||
│ │ "Adding 3 using 5's │ │ │
|
||||
│ │ friend" │ │ │
|
||||
│ │ │ │ │
|
||||
│ │ [Learn This First] │ │ │
|
||||
│ │ [Skip for Now] │ │ │
|
||||
│ └─────────────────────────┘ │ │
|
||||
│ ↓ │ │
|
||||
│ TUTORIAL │ │
|
||||
│ (3 guided examples) │ │
|
||||
│ ↓ │ │
|
||||
│ Add to isPracticing │ │
|
||||
│ ↓ │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ PRACTICE SESSION │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1. Session Summary: Celebrate, Don't Assign
|
||||
|
||||
After a session, celebrate unlocks but DON'T make them do a tutorial - they're tired!
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ SESSION COMPLETE │
|
||||
│ │
|
||||
│ Great work today! │
|
||||
│ │
|
||||
│ ✓ 12 problems completed │
|
||||
│ ✓ 83% accuracy │
|
||||
│ │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ 🎉 You've unlocked a new skill! │
|
||||
│ │
|
||||
│ "+3 Five-Complement" is now │
|
||||
│ available to learn. │
|
||||
│ │
|
||||
│ It'll be waiting for you next time! │
|
||||
│ │
|
||||
│ [Done] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
No tutorial button. Just celebration.
|
||||
|
||||
### 2. Skills Dashboard (includes Teacher Anomalies pane)
|
||||
|
||||
Shows progression state with readiness indicator and teacher notes:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ YOUR SKILLS │
|
||||
│ │
|
||||
│ Currently Practicing │
|
||||
│ ─────────────────── │
|
||||
│ ✓ +1 Direct (mastered) │
|
||||
│ ✓ +2 Direct (mastered) │
|
||||
│ ○ +3 Direct (learning - 65%) │
|
||||
│ │
|
||||
│ Ready to Learn │
|
||||
│ ─────────────────── │
|
||||
│ 📚 +4 Direct │
|
||||
│ Start a session to learn this │
|
||||
│ [Start Session with Tutorial] │
|
||||
│ │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ ⚠️ Teacher Notes │
|
||||
│ ─────────────────── │
|
||||
│ • "basic.heavenBead" - mastered but │
|
||||
│ not in practice rotation │
|
||||
│ [Re-add] [Dismiss] │
|
||||
│ │
|
||||
│ • "+4 Direct" - tutorial skipped │
|
||||
│ 3 times │
|
||||
│ [Mark as learned] [Investigate] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The "Start Session with Tutorial" button goes straight to the tutorial, then into practice.
|
||||
|
||||
### 3. ManualSkillSelector (Teacher Override)
|
||||
|
||||
Add teacher override capability:
|
||||
|
||||
```tsx
|
||||
// In ManualSkillSelector.tsx
|
||||
|
||||
function SkillRow({ skill, tutorialProgress, onToggle, onOverride }) {
|
||||
const needsTutorial =
|
||||
!tutorialProgress?.tutorialCompleted && !tutorialProgress?.teacherOverride;
|
||||
|
||||
return (
|
||||
<div data-skill={skill.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skill.isPracticing}
|
||||
onChange={onToggle}
|
||||
disabled={needsTutorial && !skill.isPracticing}
|
||||
/>
|
||||
<span>{skill.displayName}</span>
|
||||
|
||||
{needsTutorial && (
|
||||
<span data-status="needs-tutorial">
|
||||
📚 Needs tutorial
|
||||
<button
|
||||
onClick={() => onOverride(skill.id)}
|
||||
title="Mark as learned offline"
|
||||
>
|
||||
Override
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{tutorialProgress?.teacherOverride && (
|
||||
<span data-status="override">
|
||||
✓ Teacher override
|
||||
{tutorialProgress.overrideReason && (
|
||||
<span>({tutorialProgress.overrideReason})</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### UI Touchpoint Summary
|
||||
|
||||
| Touchpoint | What happens |
|
||||
| ------------------------ | ------------------------------------------------------------------------------ |
|
||||
| **Start Practice Modal** | PRIMARY GATE - Tutorial offered here before session starts |
|
||||
| **Session Summary** | Celebrate unlock, no action required |
|
||||
| **Skills Dashboard** | Shows readiness + teacher anomalies pane, offers "start session with tutorial" |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Data Foundation (1-2 hours)
|
||||
|
||||
- [ ] Create `skill_tutorial_progress` schema
|
||||
- [ ] Create migration
|
||||
- [ ] Add CRUD operations in `progress-manager.ts`
|
||||
|
||||
### Phase 2: Skill Tutorial Config (2-3 hours)
|
||||
|
||||
- [ ] Create `src/lib/curriculum/skill-tutorial-config.ts`
|
||||
- [ ] Map all ~30 skills to example problems
|
||||
- [ ] Add display names for skills
|
||||
|
||||
### Phase 3: Gap Detection (2-3 hours)
|
||||
|
||||
- [ ] Implement `computeUnlockSuggestions()`
|
||||
- [ ] Implement `findHighestMasteredPhase()`
|
||||
- [ ] Unit tests for gap detection scenarios:
|
||||
- Normal progression (no gaps)
|
||||
- Gap in five-complements
|
||||
- Gap in basic skills
|
||||
- Multiple gaps
|
||||
|
||||
### Phase 4: Tutorial Launcher (3-4 hours)
|
||||
|
||||
- [ ] Create `SkillTutorialLauncher` component
|
||||
- [ ] Integrate with existing `TutorialPlayer`
|
||||
- [ ] Handle tutorial completion tracking
|
||||
- [ ] Test with various skill types
|
||||
|
||||
### Phase 5: UI Integration (2-3 hours)
|
||||
|
||||
- [ ] Add to Session Summary
|
||||
- [ ] Create Skills Dashboard progression view
|
||||
- [ ] Update ManualSkillSelector with tutorial gating
|
||||
- [ ] Add teacher override modal
|
||||
|
||||
### Phase 6: Testing & Polish (2-3 hours)
|
||||
|
||||
- [ ] End-to-end flow testing
|
||||
- [ ] Edge cases (no skills practicing, all mastered, etc.)
|
||||
- [ ] Mobile responsiveness
|
||||
- [ ] Accessibility review
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Gap Detection Tests
|
||||
|
||||
```typescript
|
||||
describe("Gap Detection", () => {
|
||||
it("identifies gap when five-complement missing but ten-complement mastered", async () => {
|
||||
// Setup: Student has mastered +7=10-3 but never learned -2=-5+3
|
||||
await setMasteredSkill(playerId, "tenComplements.7=10-3");
|
||||
// -2=-5+3 is in L1, should be unlocked before L2 ten-complements
|
||||
|
||||
const suggestions = await computeUnlockSuggestions(playerId);
|
||||
|
||||
expect(suggestions[0]).toMatchObject({
|
||||
skillId: "fiveComplementsSub.-2=-5+3",
|
||||
type: "gap",
|
||||
});
|
||||
});
|
||||
|
||||
it("suggests advancement when no gaps exist", async () => {
|
||||
// Setup: All L1 skills mastered
|
||||
await masterAllL1Skills(playerId);
|
||||
|
||||
const suggestions = await computeUnlockSuggestions(playerId);
|
||||
|
||||
expect(suggestions[0]).toMatchObject({
|
||||
type: "advancement",
|
||||
// First L2 skill
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks advancement until all gaps filled", async () => {
|
||||
// Setup: Two gaps exist
|
||||
await setMasteredSkill(playerId, "tenComplements.9=10-1");
|
||||
// Missing: basic.heavenBead and fiveComplements.3=5-2
|
||||
|
||||
const suggestions = await computeUnlockSuggestions(playerId);
|
||||
|
||||
// Should suggest gaps first, ordered by curriculum
|
||||
expect(suggestions.length).toBe(2);
|
||||
expect(suggestions[0].type).toBe("gap");
|
||||
expect(suggestions[1].type).toBe("gap");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open Questions (Resolved)
|
||||
|
||||
| Question | Decision |
|
||||
| ------------------------------------- | ------------------------------------------------------- |
|
||||
| Gap-fill before advancement? | **STRICT** - Must fill all gaps before advancing |
|
||||
| Auto-generated vs authored tutorials? | **AUTO** - Use `generateUnifiedInstructionSequence()` |
|
||||
| Tutorial thoroughness? | **THOROUGH** - 3 guided examples with explanations |
|
||||
| Teacher override? | **YES** - Teachers can mark skills as "learned offline" |
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/db/schema/skill-tutorial-progress.ts` - DB schema
|
||||
- `drizzle/XXXX_skill_tutorial_progress.sql` - Migration
|
||||
- `src/lib/curriculum/skill-tutorial-config.ts` - Skill → tutorial mapping
|
||||
- `src/lib/curriculum/skill-unlock.ts` - Gap detection algorithm
|
||||
- `src/components/tutorial/SkillTutorialLauncher.tsx` - Tutorial launcher
|
||||
- `src/app/api/curriculum/[playerId]/tutorial-progress/route.ts` - API
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `src/lib/curriculum/progress-manager.ts` - Add tutorial progress CRUD
|
||||
- `src/components/practice/SessionSummary.tsx` - Add unlock prompts
|
||||
- `src/components/practice/ManualSkillSelector.tsx` - Add tutorial gating
|
||||
- `src/app/practice/[studentId]/skills/SkillsClient.tsx` - Add progression view
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This integration plan leverages the existing powerful tutorial system to create a seamless skill progression experience:
|
||||
|
||||
1. **BKT identifies mastery** → triggers unlock suggestion
|
||||
2. **Gap detection ensures curriculum integrity** → prerequisites before advancement
|
||||
3. **Dynamic tutorial generation** → no manual authoring needed
|
||||
4. **Tutorial completion gates practice** → conceptual understanding before fluency drilling
|
||||
5. **Teacher override available** → for offline learning scenarios
|
||||
|
||||
The key insight is that `generateUnifiedInstructionSequence()` already does all the heavy lifting for tutorial content. We just need to configure which problems demonstrate which skills and wire up the progression logic.
|
||||
@@ -358,16 +358,17 @@ export function DecompositionProvider({
|
||||
**File:** `src/components/decomposition/DecompositionDisplay.tsx`
|
||||
|
||||
This will be a refactored version of `DecompositionWithReasons` that:
|
||||
|
||||
1. Uses `useDecomposition()` instead of `useTutorialContext()`
|
||||
2. Receives no props (gets everything from context)
|
||||
3. Can be dropped anywhere inside a `DecompositionProvider`
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useDecomposition } from '@/contexts/DecompositionContext'
|
||||
import { ReasonTooltip } from './ReasonTooltip' // Moved here
|
||||
import './decomposition.css'
|
||||
import { useDecomposition } from "@/contexts/DecompositionContext";
|
||||
import { ReasonTooltip } from "./ReasonTooltip"; // Moved here
|
||||
import "./decomposition.css";
|
||||
|
||||
export function DecompositionDisplay() {
|
||||
const {
|
||||
@@ -380,7 +381,7 @@ export function DecompositionDisplay() {
|
||||
activeIndividualTermIndex,
|
||||
handleTermHover,
|
||||
getGroupTermIndicesFromTermIndex,
|
||||
} = useDecomposition()
|
||||
} = useDecomposition();
|
||||
|
||||
// ... rendering logic (adapted from DecompositionWithReasons)
|
||||
}
|
||||
@@ -406,6 +407,7 @@ function SegmentGroup({ segment, steps, ... }) {
|
||||
### Step 4: Update ReasonTooltip
|
||||
|
||||
The tooltip already has a conditional import pattern for TutorialUIContext. We keep that but also:
|
||||
|
||||
1. Move it to `src/components/decomposition/ReasonTooltip.tsx`
|
||||
2. Receive `steps` as a prop instead of from context
|
||||
|
||||
@@ -491,21 +493,25 @@ src/
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Create New Context (Non-Breaking)
|
||||
|
||||
1. Create `DecompositionContext.tsx` with all logic
|
||||
2. Create `DecompositionDisplay.tsx` using new context
|
||||
3. Keep existing `DecompositionWithReasons.tsx` working
|
||||
|
||||
### Phase 2: Update TutorialPlayer
|
||||
|
||||
1. Wrap decomposition area with `DecompositionProvider`
|
||||
2. Update TutorialPlayer to sync state via callbacks
|
||||
3. Verify tutorial still works identically
|
||||
|
||||
### Phase 3: Integrate into Practice
|
||||
|
||||
1. Add `DecompositionProvider` to help panel
|
||||
2. Render `DecompositionDisplay`
|
||||
3. Test practice help flow
|
||||
|
||||
### Phase 4: Cleanup (Optional)
|
||||
|
||||
1. Remove decomposition logic from `TutorialContext`
|
||||
2. Delete old `DecompositionWithReasons.tsx`
|
||||
3. Update imports throughout codebase
|
||||
@@ -513,6 +519,7 @@ src/
|
||||
## Testing Checklist
|
||||
|
||||
### Tutorial Mode
|
||||
|
||||
- [ ] Decomposition shows correctly for each step
|
||||
- [ ] Current step is highlighted
|
||||
- [ ] Term hover shows tooltip
|
||||
@@ -521,6 +528,7 @@ src/
|
||||
- [ ] Abacus column hover highlights related terms
|
||||
|
||||
### Practice Mode
|
||||
|
||||
- [ ] Decomposition shows when help is active
|
||||
- [ ] Correct decomposition for current term (start → target)
|
||||
- [ ] Tooltips work on hover
|
||||
@@ -528,6 +536,7 @@ src/
|
||||
- [ ] No console errors
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- [ ] Single-digit addition (no meaningful decomposition)
|
||||
- [ ] Multi-column carries
|
||||
- [ ] Complement operations (five/ten complements)
|
||||
@@ -536,24 +545,28 @@ src/
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Breaking tutorial functionality | Phase 2: Keep old code working in parallel during migration |
|
||||
| Performance: Re-generating sequence | useMemo ensures sequence only regenerates on value changes |
|
||||
| CSS conflicts | Move CSS to shared location, use consistent naming |
|
||||
| Missing data in practice context | `usePracticeHelp` already generates sequence - verify compatibility |
|
||||
| Risk | Mitigation |
|
||||
| ----------------------------------- | ------------------------------------------------------------------- |
|
||||
| Breaking tutorial functionality | Phase 2: Keep old code working in parallel during migration |
|
||||
| Performance: Re-generating sequence | useMemo ensures sequence only regenerates on value changes |
|
||||
| CSS conflicts | Move CSS to shared location, use consistent naming |
|
||||
| Missing data in practice context | `usePracticeHelp` already generates sequence - verify compatibility |
|
||||
|
||||
## Notes
|
||||
|
||||
### Why Not Just Pass Props?
|
||||
|
||||
We could pass all data as props, but:
|
||||
|
||||
1. Deep prop drilling through TermSpan, SegmentGroup, ReasonTooltip
|
||||
2. Many components need same data
|
||||
3. Interactive state (hover) needs to be shared
|
||||
4. Context pattern is cleaner and more React-idiomatic
|
||||
|
||||
### Compatibility with usePracticeHelp
|
||||
|
||||
The `usePracticeHelp` hook already calls `generateUnifiedInstructionSequence()` and stores the result. For practice mode, we have two options:
|
||||
|
||||
1. **Option A:** Let `DecompositionProvider` regenerate (simple, slightly redundant)
|
||||
2. **Option B:** Accept pre-generated `sequence` as prop (more efficient)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
**Why:** Makes help discoverable without reading - kid just enters what's on their abacus and help appears.
|
||||
|
||||
**Key insight:** We already have all the coaching/decomposition infrastructure extracted. Only need to:
|
||||
|
||||
1. Extract bead tooltip positioning from TutorialPlayer
|
||||
2. Build new overlay component using existing decomposition system
|
||||
3. Wire up time-based escalation
|
||||
@@ -28,11 +29,11 @@
|
||||
|
||||
## Time-Based Escalation
|
||||
|
||||
| Time | What appears |
|
||||
|------|--------------|
|
||||
| 0s | Abacus with arrows |
|
||||
| +5s (debug: 1s) | Coach hint (from decomposition system) |
|
||||
| +10s (debug: 3s) | Bead tooltip pointing at beads |
|
||||
| Time | What appears |
|
||||
| ---------------- | -------------------------------------- |
|
||||
| 0s | Abacus with arrows |
|
||||
| +5s (debug: 1s) | Coach hint (from decomposition system) |
|
||||
| +10s (debug: 3s) | Bead tooltip pointing at beads |
|
||||
|
||||
## Shared Infrastructure (Already Exists)
|
||||
|
||||
@@ -49,15 +50,15 @@
|
||||
|
||||
## Files
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `src/utils/beadTooltipUtils.ts` | CREATE - extracted tooltip utils |
|
||||
| `src/constants/helpTiming.ts` | CREATE - timing config |
|
||||
| `src/components/practice/PracticeHelpOverlay.tsx` | CREATE - main component |
|
||||
| `src/components/practice/PracticeHelpOverlay.stories.tsx` | CREATE - stories |
|
||||
| `src/components/practice/HelpAbacus.tsx` | MODIFY - add overlays prop |
|
||||
| `src/components/practice/ActiveSession.tsx` | MODIFY - integrate overlay |
|
||||
| `src/components/tutorial/TutorialPlayer.tsx` | MODIFY - use shared utils |
|
||||
| File | Action |
|
||||
| --------------------------------------------------------- | -------------------------------- |
|
||||
| `src/utils/beadTooltipUtils.ts` | CREATE - extracted tooltip utils |
|
||||
| `src/constants/helpTiming.ts` | CREATE - timing config |
|
||||
| `src/components/practice/PracticeHelpOverlay.tsx` | CREATE - main component |
|
||||
| `src/components/practice/PracticeHelpOverlay.stories.tsx` | CREATE - stories |
|
||||
| `src/components/practice/HelpAbacus.tsx` | MODIFY - add overlays prop |
|
||||
| `src/components/practice/ActiveSession.tsx` | MODIFY - integrate overlay |
|
||||
| `src/components/tutorial/TutorialPlayer.tsx` | MODIFY - use shared utils |
|
||||
|
||||
## Deferred
|
||||
|
||||
|
||||
311
apps/web/.claude/plans/react-query-migration.md
Normal file
311
apps/web/.claude/plans/react-query-migration.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# Plan: Migrate Dashboard to React Query
|
||||
|
||||
## Problem Statement
|
||||
|
||||
`DashboardClient.tsx` has 3 direct `fetch()` calls that bypass React Query:
|
||||
|
||||
1. `handleStartOver` - abandons session
|
||||
2. `handleSaveManualSkills` - sets mastered skills
|
||||
3. `handleRefreshSkill` - refreshes skill recency
|
||||
|
||||
These use `router.refresh()` to update data, but this doesn't work reliably because:
|
||||
|
||||
- `router.refresh()` re-runs server components but doesn't guarantee client state updates
|
||||
- The React Query cache is not invalidated, so other components see stale data
|
||||
- There's a race condition between navigation and data refresh
|
||||
|
||||
## Root Cause
|
||||
|
||||
`DashboardClient` receives data as **server-side props** and doesn't use React Query hooks:
|
||||
|
||||
```typescript
|
||||
// Current: Props-based data
|
||||
export function DashboardClient({
|
||||
activeSession, // Server prop - stale after mutations
|
||||
skills, // Server prop - stale after mutations
|
||||
...
|
||||
}: DashboardClientProps) {
|
||||
```
|
||||
|
||||
Meanwhile, React Query mutations exist in `useSessionPlan.ts` and `usePlayerCurriculum.ts` but aren't used here.
|
||||
|
||||
## Solution: Use React Query Hooks with Server Props as Initial Data
|
||||
|
||||
### Pattern: Hydrate React Query from Server Props
|
||||
|
||||
```typescript
|
||||
// New: Use hooks with server props as initial data
|
||||
export function DashboardClient({
|
||||
activeSession: initialActiveSession,
|
||||
skills: initialSkills,
|
||||
...
|
||||
}: DashboardClientProps) {
|
||||
// Use React Query with server props as initial data
|
||||
const { data: activeSession } = useActiveSessionPlan(studentId, initialActiveSession)
|
||||
|
||||
// Use mutation instead of direct fetch
|
||||
const abandonMutation = useAbandonSession()
|
||||
|
||||
const handleStartOver = useCallback(async () => {
|
||||
if (!activeSession) return
|
||||
setIsStartingOver(true)
|
||||
try {
|
||||
await abandonMutation.mutateAsync({ playerId: studentId, planId: activeSession.id })
|
||||
router.push(`/practice/${studentId}/configure`)
|
||||
} catch (error) {
|
||||
console.error('Failed to start over:', error)
|
||||
} finally {
|
||||
setIsStartingOver(false)
|
||||
}
|
||||
}, [activeSession, studentId, abandonMutation, router])
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add Missing React Query Mutation for Skills
|
||||
|
||||
**File:** `src/hooks/usePlayerCurriculum.ts`
|
||||
|
||||
The skills mutations (`setMasteredSkills`, `refreshSkillRecency`) aren't currently exported. Add them:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Hook: Set mastered skills (manual skill management)
|
||||
*/
|
||||
export function useSetMasteredSkills() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
playerId,
|
||||
masteredSkillIds,
|
||||
}: {
|
||||
playerId: string;
|
||||
masteredSkillIds: string[];
|
||||
}) => {
|
||||
const res = await api(`curriculum/${playerId}/skills`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ masteredSkillIds }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new Error(error.error || "Failed to set mastered skills");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (_, { playerId }) => {
|
||||
// Invalidate curriculum to refetch skills
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: curriculumKeys.detail(playerId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Refresh skill recency (mark as recently practiced)
|
||||
*/
|
||||
export function useRefreshSkillRecency() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
playerId,
|
||||
skillId,
|
||||
}: {
|
||||
playerId: string;
|
||||
skillId: string;
|
||||
}) => {
|
||||
const res = await api(`curriculum/${playerId}/skills`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ skillId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new Error(error.error || "Failed to refresh skill");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (_, { playerId }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: curriculumKeys.detail(playerId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Update DashboardClient to Use React Query
|
||||
|
||||
**File:** `src/app/practice/[studentId]/dashboard/DashboardClient.tsx`
|
||||
|
||||
1. Add imports:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
useAbandonSession,
|
||||
useActiveSessionPlan,
|
||||
} from "@/hooks/useSessionPlan";
|
||||
import {
|
||||
useSetMasteredSkills,
|
||||
useRefreshSkillRecency,
|
||||
} from "@/hooks/usePlayerCurriculum";
|
||||
```
|
||||
|
||||
2. Use hooks with server props as initial data:
|
||||
|
||||
```typescript
|
||||
export function DashboardClient({
|
||||
studentId,
|
||||
player,
|
||||
curriculum,
|
||||
skills,
|
||||
recentSessions,
|
||||
activeSession: initialActiveSession,
|
||||
currentPracticingSkillIds,
|
||||
problemHistory,
|
||||
initialTab = 'overview',
|
||||
}: DashboardClientProps) {
|
||||
// Use React Query for active session (server prop as initial data)
|
||||
const { data: activeSession } = useActiveSessionPlan(studentId, initialActiveSession)
|
||||
|
||||
// Mutations
|
||||
const abandonMutation = useAbandonSession()
|
||||
const setMasteredSkillsMutation = useSetMasteredSkills()
|
||||
const refreshSkillMutation = useRefreshSkillRecency()
|
||||
```
|
||||
|
||||
3. Replace direct fetch handlers:
|
||||
|
||||
```typescript
|
||||
const handleStartOver = useCallback(async () => {
|
||||
if (!activeSession) return;
|
||||
setIsStartingOver(true);
|
||||
try {
|
||||
await abandonMutation.mutateAsync({
|
||||
playerId: studentId,
|
||||
planId: activeSession.id,
|
||||
});
|
||||
router.push(`/practice/${studentId}/configure`);
|
||||
} catch (error) {
|
||||
console.error("Failed to start over:", error);
|
||||
} finally {
|
||||
setIsStartingOver(false);
|
||||
}
|
||||
}, [activeSession, studentId, abandonMutation, router]);
|
||||
|
||||
const handleSaveManualSkills = useCallback(
|
||||
async (masteredSkillIds: string[]) => {
|
||||
await setMasteredSkillsMutation.mutateAsync({
|
||||
playerId: studentId,
|
||||
masteredSkillIds,
|
||||
});
|
||||
setShowManualSkillModal(false);
|
||||
},
|
||||
[studentId, setMasteredSkillsMutation],
|
||||
);
|
||||
|
||||
const handleRefreshSkill = useCallback(
|
||||
async (skillId: string) => {
|
||||
await refreshSkillMutation.mutateAsync({
|
||||
playerId: studentId,
|
||||
skillId,
|
||||
});
|
||||
},
|
||||
[studentId, refreshSkillMutation],
|
||||
);
|
||||
```
|
||||
|
||||
4. Remove router.refresh() calls - they're no longer needed.
|
||||
|
||||
### Step 3: Add Skills Query Hook (Optional Enhancement)
|
||||
|
||||
For full consistency, skills should also come from React Query. Add to `usePlayerCurriculum.ts`:
|
||||
|
||||
```typescript
|
||||
export function usePlayerSkills(
|
||||
playerId: string,
|
||||
initialData?: PlayerSkillMastery[],
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...curriculumKeys.detail(playerId), "skills"],
|
||||
queryFn: async () => {
|
||||
const res = await api(`curriculum/${playerId}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch curriculum");
|
||||
const data = await res.json();
|
||||
return data.skills as PlayerSkillMastery[];
|
||||
},
|
||||
initialData,
|
||||
staleTime: initialData ? 30000 : 0,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Then in DashboardClient:
|
||||
|
||||
```typescript
|
||||
const { data: skills } = usePlayerSkills(studentId, initialSkills);
|
||||
```
|
||||
|
||||
### Step 4: Ensure QueryClient Provider Wraps Practice Pages
|
||||
|
||||
**File:** `src/app/practice/[studentId]/layout.tsx` (or similar)
|
||||
|
||||
Verify that `QueryClientProvider` is available. It should be in the root layout, but verify:
|
||||
|
||||
```typescript
|
||||
// src/app/providers.tsx or similar
|
||||
'use client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
| ------------------------------------------------------------ | ---------------------------------------------------- |
|
||||
| `src/hooks/usePlayerCurriculum.ts` | Add `useSetMasteredSkills`, `useRefreshSkillRecency` |
|
||||
| `src/app/practice/[studentId]/dashboard/DashboardClient.tsx` | Use React Query hooks, remove direct fetch |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Click "Start Over" → session abandons, UI updates immediately
|
||||
- [ ] Click "Start Over" → navigate to /configure works
|
||||
- [ ] Click "Start Over" → if navigation fails, dashboard shows no active session
|
||||
- [ ] Manage Skills → save changes → Skills tab updates immediately
|
||||
- [ ] Refresh skill recency → skill card updates (staleness warning clears)
|
||||
- [ ] Multiple browser tabs → mutation in one reflects in other after refocus
|
||||
|
||||
## Why This Works
|
||||
|
||||
1. **Server props hydrate React Query cache** - No loading flash on initial render
|
||||
2. **Mutations update cache** - `abandonMutation.mutateAsync()` sets active session to `null`
|
||||
3. **Components read from cache** - `useActiveSessionPlan` returns fresh data
|
||||
4. **No router.refresh() needed** - React Query manages state, not Next.js
|
||||
5. **Consistent across components** - Any component using these hooks sees the same data
|
||||
|
||||
## Rollout Risk
|
||||
|
||||
Low risk:
|
||||
|
||||
- Existing hooks already tested in other practice components
|
||||
- Server props still provide initial data (no loading states)
|
||||
- Incremental change - only DashboardClient affected
|
||||
@@ -1,138 +1,76 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:react-resizable-panels.vercel.app)",
|
||||
"Bash(gh run watch:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(NODE_ENV=production npm run build:*)",
|
||||
"Bash(npx @pandacss/dev:*)",
|
||||
"Bash(npm run build-storybook:*)",
|
||||
"Bash(ssh nas.home.network:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(curl:*)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:community.home-assistant.io)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"WebFetch(domain:www.google.com)",
|
||||
"Bash(gcloud auth list:*)",
|
||||
"Bash(gcloud auth login:*)",
|
||||
"Bash(gcloud projects list:*)",
|
||||
"Bash(gcloud projects create:*)",
|
||||
"Bash(gcloud config set:*)",
|
||||
"Bash(gcloud services enable:*)",
|
||||
"Bash(gcloud alpha services api-keys create:*)",
|
||||
"Bash(gcloud components install:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(./fetch-streetview.sh:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(npx @biomejs/biome lint:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(npm run type-check:*)",
|
||||
"Bash(npm run pre-commit:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(npm info:*)",
|
||||
"Bash(gh run list:*)",
|
||||
"Bash(ssh:*)",
|
||||
"Bash(git fetch:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\ndocs: add comprehensive merge conflict resolution guide\n\nAdd detailed guide for intelligent diff3-style merge conflict resolution:\n- Explanation of diff3 format (OURS, BASE, THEIRS)\n- 5 resolution patterns with examples (Compatible, Redundant, Conflicting, Delete vs Modify, Rename + References)\n- zdiff3 modern alternative\n- Semantic merge concepts\n- Best practices and anti-patterns\n- Debugging guide for failed resolutions\n- Quick reference checklist\n\nThis guide helps resolve merge conflicts intelligently by understanding the intent of both sides'' changes.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\ndocs: add merge conflict resolution section to CLAUDE.md\n\nAdd quick reference section for merge conflict resolution:\n- Link to comprehensive guide (.claude/MERGE_CONFLICT_RESOLUTION.md)\n- Enable zdiff3 command\n- Quick resolution strategy summary\n- Reminder to test thoroughly after resolution\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nchore: add auto-approvals for development commands\n\nAdd auto-approvals for common development workflow commands:\n- npm run type-check\n- npm run pre-commit \n- git add\n- npm info\n- npx tsc\n\nThese commands are safe to run automatically during development and code quality checks.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(/tmp/worksheet-preview-new.tsx)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(pkill:*)",
|
||||
"Bash(git rev-parse:*)",
|
||||
"Bash(sqlite3:*)",
|
||||
"Bash(gh run view:*)",
|
||||
"Bash(gh run rerun:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(scp:*)",
|
||||
"Bash(rsync:*)",
|
||||
"Bash(npm run format:*)",
|
||||
"Bash(npm run lint:fix:*)",
|
||||
"Bash(npm run lint)",
|
||||
"mcp__sqlite__read_query",
|
||||
"mcp__sqlite__describe_table",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git pull:*)",
|
||||
"Bash(git stash:*)",
|
||||
"Bash(npx @biomejs/biome:*)",
|
||||
"Bash(git rev-parse:*)",
|
||||
"Bash(gh run list:*)",
|
||||
"Bash(npx biome:*)",
|
||||
"WebFetch(domain:www.macintoshrepository.org)",
|
||||
"WebFetch(domain:www.npmjs.com)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(node -e:*)",
|
||||
"Bash(npm search:*)",
|
||||
"Bash(git revert:*)",
|
||||
"Bash(pnpm remove:*)",
|
||||
"Bash(gh run view:*)",
|
||||
"Bash(pnpm install:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(node server.js:*)",
|
||||
"Bash(git fetch:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(npm run test:run:*)",
|
||||
"Bash(for:*)",
|
||||
"Bash(do sleep 30)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(done)",
|
||||
"Bash(do sleep 120)",
|
||||
"Bash(node --version)",
|
||||
"Bash(docker run:*)",
|
||||
"Bash(docker pull:*)",
|
||||
"Bash(docker inspect:*)",
|
||||
"Bash(docker system prune:*)",
|
||||
"Bash(docker stop:*)",
|
||||
"Bash(docker rm:*)",
|
||||
"Bash(docker logs:*)",
|
||||
"Bash(docker exec:*)",
|
||||
"Bash(node --input-type=module -e:*)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(npx tsx:*)",
|
||||
"Bash(tsc:*)",
|
||||
"Bash(npx @biomejs/biome check:*)",
|
||||
"Bash(npx vitest:*)",
|
||||
"Bash(ssh:*)",
|
||||
"Bash(break)",
|
||||
"Bash(node -e:*)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(npx @biomejs/biome format:*)",
|
||||
"Bash(npm run lint:*)",
|
||||
"WebFetch(domain:strudel.cc)",
|
||||
"WebFetch(domain:club.tidalcycles.org)",
|
||||
"Bash(git reset:*)",
|
||||
"WebFetch(domain:abaci.one)",
|
||||
"Bash(awk:*)",
|
||||
"Bash(sort:*)",
|
||||
"Bash(apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx )",
|
||||
"Bash(apps/web/src/arcade-games/know-your-world/docs/MAPRENDERER_REFACTORING_PLAN.md )",
|
||||
"Bash(apps/web/src/arcade-games/know-your-world/features/magnifier/index.ts )",
|
||||
"Bash(apps/web/src/arcade-games/know-your-world/features/magnifier/useMagnifierStyle.ts )",
|
||||
"Bash(apps/web/src/arcade-games/know-your-world/features/cursor/ )",
|
||||
"Bash(apps/web/src/arcade-games/know-your-world/features/interaction/ )",
|
||||
"Bash(apps/web/src/arcade-games/know-your-world/utils/heatStyles.ts)",
|
||||
"Bash(ping:*)",
|
||||
"WebFetch(domain:typst.app)",
|
||||
"WebFetch(domain:finemotormath.com)",
|
||||
"WebFetch(domain:learnabacusathome.com)",
|
||||
"WebFetch(domain:totton.idirect.com)",
|
||||
"Bash(git rebase:*)",
|
||||
"Bash(git stash:*)",
|
||||
"Bash(git stash pop:*)",
|
||||
"Bash(npx drizzle-kit:*)",
|
||||
"Bash(npm run db:migrate:*)",
|
||||
"mcp__sqlite__list_tables",
|
||||
"Bash(sqlite3:*)",
|
||||
"Bash(npx eslint:*)",
|
||||
"Bash(src/hooks/useDeviceCapabilities.ts )",
|
||||
"Bash(src/arcade-games/know-your-world/hooks/useDeviceCapabilities.ts )",
|
||||
"Bash(src/components/practice/hooks/useDeviceDetection.ts )",
|
||||
"Bash(src/arcade-games/memory-quiz/components/InputPhase.tsx )",
|
||||
"Bash(src/app/api/curriculum/*/sessions/plans/route.ts)",
|
||||
"Bash(src/app/api/curriculum/*/sessions/plans/*/route.ts)",
|
||||
"Bash(src/components/practice/SessionSummary.tsx )",
|
||||
"Bash(src/components/practice/ )",
|
||||
"Bash(src/app/practice/ )",
|
||||
"Bash(src/app/api/curriculum/ )",
|
||||
"Bash(src/hooks/usePlayerCurriculum.ts )",
|
||||
"Bash(src/hooks/useSessionPlan.ts )",
|
||||
"Bash(src/lib/curriculum/ )",
|
||||
"Bash(src/db/schema/player-curriculum.ts )",
|
||||
"Bash(src/db/schema/player-skill-mastery.ts )",
|
||||
"Bash(src/db/schema/practice-sessions.ts )",
|
||||
"Bash(src/db/schema/session-plans.ts )",
|
||||
"Bash(src/db/schema/index.ts )",
|
||||
"Bash(src/types/tutorial.ts )",
|
||||
"Bash(src/utils/problemGenerator.ts )",
|
||||
"Bash(drizzle/ )",
|
||||
"Bash(docs/DAILY_PRACTICE_SYSTEM.md )",
|
||||
"Bash(../../README.md )",
|
||||
"Bash(.claude/CLAUDE.md)",
|
||||
"Bash(mcp__sqlite__describe_table:*)",
|
||||
"Bash(ls:*)"
|
||||
"mcp__sqlite__read_query",
|
||||
"Bash(ls:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(DEBUG_COST_CALCULATOR=true npx vitest:*)",
|
||||
"Bash(DEBUG_SESSION_PLANNER=true npx vitest run:*)",
|
||||
"Bash(tee:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(npx tsx:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(src/app/blog/\\[slug\\]/page.tsx )",
|
||||
"Bash(src/components/blog/ValidationCharts.tsx )",
|
||||
"Bash(src/lib/curriculum/bkt/compute-bkt.ts )",
|
||||
"Bash(src/lib/curriculum/bkt/conjunctive-bkt.ts )",
|
||||
"Bash(src/lib/curriculum/bkt/index.ts )",
|
||||
"Bash(src/test/journey-simulator/JourneyRunner.ts )",
|
||||
"Bash(src/test/journey-simulator/types.ts )",
|
||||
"Bash(src/test/journey-simulator/blame-attribution.test.ts )",
|
||||
"Bash(src/test/journey-simulator/__snapshots__/blame-attribution.test.ts.snap)",
|
||||
"Bash(\"src/app/blog/[slug]/page.tsx\" )",
|
||||
"Bash(\"src/components/blog/ValidationCharts.tsx\" )",
|
||||
"Bash(\"src/lib/curriculum/bkt/compute-bkt.ts\" )",
|
||||
"Bash(\"src/lib/curriculum/bkt/conjunctive-bkt.ts\" )",
|
||||
"Bash(\"src/lib/curriculum/bkt/index.ts\" )",
|
||||
"Bash(\"src/test/journey-simulator/JourneyRunner.ts\" )",
|
||||
"Bash(\"src/test/journey-simulator/types.ts\" )",
|
||||
"Bash(\"src/test/journey-simulator/blame-attribution.test.ts\" )",
|
||||
"WebSearch",
|
||||
"Bash(npm run format:check:*)",
|
||||
"Bash(ping:*)",
|
||||
"Bash(dig:*)",
|
||||
"Bash(pnpm why:*)",
|
||||
"Bash(npm view:*)",
|
||||
"Bash(pnpm install:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { AbacusDisplayProvider } from '@soroban/abacus-react'
|
||||
import type { Preview } from '@storybook/nextjs'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import React from 'react'
|
||||
import { ThemeProvider } from '../src/contexts/ThemeContext'
|
||||
import tutorialEn from '../src/i18n/locales/tutorial/en.json'
|
||||
import '../styled-system/styles.css'
|
||||
|
||||
// Merge messages for Storybook (add more as needed)
|
||||
const messages = {
|
||||
tutorial: tutorialEn.tutorial,
|
||||
}
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
@@ -15,7 +23,11 @@ const preview: Preview = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ThemeProvider>
|
||||
<Story />
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<AbacusDisplayProvider>
|
||||
<Story />
|
||||
</AbacusDisplayProvider>
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
),
|
||||
],
|
||||
|
||||
@@ -29,17 +29,17 @@ npm run pre-commit
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| Component | Description |
|
||||
| ----------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| [Decomposition Display](./src/components/decomposition/README.md) | Interactive mathematical decomposition visualization |
|
||||
| [Worksheet Generator](./src/app/create/worksheets/README.md) | Math worksheet creation with Typst PDF generation |
|
||||
| [Worksheet Generator](./src/app/create/worksheets/README.md) | Math worksheet creation with Typst PDF generation |
|
||||
|
||||
### Games
|
||||
|
||||
| Game | Description |
|
||||
|------|-------------|
|
||||
| [Arcade System](./src/arcade-games/README.md) | Modular multiplayer game architecture |
|
||||
| [Know Your World](./src/arcade-games/know-your-world/README.md) | Geography quiz game |
|
||||
| Game | Description |
|
||||
| --------------------------------------------------------------- | ------------------------------------- |
|
||||
| [Arcade System](./src/arcade-games/README.md) | Modular multiplayer game architecture |
|
||||
| [Know Your World](./src/arcade-games/know-your-world/README.md) | Geography quiz game |
|
||||
|
||||
### Developer Documentation
|
||||
|
||||
|
||||
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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
610
apps/web/content/blog/conjunctive-bkt-skill-tracing.md
Normal file
610
apps/web/content/blog/conjunctive-bkt-skill-tracing.md
Normal file
@@ -0,0 +1,610 @@
|
||||
---
|
||||
title: "Binary Outcomes, Granular Insights: How We Know Which Abacus Skill Needs Work"
|
||||
description: "How we use conjunctive Bayesian Knowledge Tracing to infer which visual-motor patterns a student has automated when all we observe is 'problem correct' or 'problem incorrect'."
|
||||
author: "Abaci.one Team"
|
||||
publishedAt: "2025-12-14"
|
||||
updatedAt: "2025-12-16"
|
||||
tags:
|
||||
[
|
||||
"education",
|
||||
"machine-learning",
|
||||
"bayesian",
|
||||
"soroban",
|
||||
"knowledge-tracing",
|
||||
"adaptive-learning",
|
||||
]
|
||||
featured: true
|
||||
---
|
||||
|
||||
# Binary Outcomes, Granular Insights: How We Know Which Abacus Skill Needs Work
|
||||
|
||||
> **Abstract:** Soroban (Japanese abacus) pedagogy treats arithmetic as a sequence of visual-motor patterns to be drilled to automaticity. Each numeral operation (adding 1, adding 2, ...) in each column context is a distinct pattern; curricula explicitly sequence these patterns, requiring mastery of each before introducing the next. This creates a well-defined skill hierarchy of ~30 discrete patterns. We apply conjunctive Bayesian Knowledge Tracing to infer pattern mastery from binary problem outcomes. At problem-generation time, we simulate the abacus to tag each term with the specific patterns it exercises. Correct answers provide evidence for all tagged patterns; incorrect answers distribute blame proportionally to each pattern's estimated weakness. BKT drives both skill targeting (prioritizing weak skills for practice) and difficulty adjustment (scaling problem complexity to mastery level). Simulation studies suggest that adaptive targeting may reach mastery 25-33% faster than uniform skill distribution, though real-world validation with human learners is ongoing. Our 3-way comparison found that the benefit comes from BKT _targeting_, not the specific cost formula—using BKT for both concerns simplifies the architecture with no performance cost.
|
||||
|
||||
---
|
||||
|
||||
Soroban (Japanese abacus) pedagogy structures arithmetic as a sequence of visual-motor patterns. Each numeral operation in each column context is a distinct pattern to be drilled until automatic. Curricula explicitly sequence these patterns—master adding 1 before adding 2, master five's complements before ten's complements—creating a well-defined hierarchy of ~30 discrete skills.
|
||||
|
||||
This structure creates both an opportunity and a challenge for adaptive practice software. The opportunity: we know exactly which patterns each problem exercises. The challenge: when a student answers incorrectly, we observe only a binary outcome—**correct** or **incorrect**—but need to infer which of several patterns failed.
|
||||
|
||||
This post describes how we solve this inference problem using **Conjunctive Bayesian Knowledge Tracing (BKT)**, applied to the soroban's well-defined pattern hierarchy.
|
||||
|
||||
## Context-Dependent Patterns
|
||||
|
||||
On a soroban, adding "+4" isn't a single pattern. It's one of several distinct visual-motor sequences depending on the current state of the abacus column.
|
||||
|
||||
A soroban column has 4 earth beads and 1 heaven bead (worth 5). The earth beads that are "up" (toward the reckoning bar) contribute to the displayed value. When we say "column shows 3," that means 3 earth beads are already up—leaving only 1 earth bead available to push up.
|
||||
|
||||
**Scenario 1: Column shows 0**
|
||||
|
||||
- Earth beads available: 4 (none are up yet)
|
||||
- To add 4: Push 4 earth beads up directly
|
||||
- **Skill exercised**: `basic.directAddition`
|
||||
|
||||
**Scenario 2: Column shows 3**
|
||||
|
||||
- Earth beads available: 1 (3 are already up)
|
||||
- To add 4: Can't push 4 beads directly—only 1 is available!
|
||||
- Operation: Lower the heaven bead (+5), then raise 1 earth bead back (-1)
|
||||
- **Skill exercised**: `fiveComplements.4=5-1`
|
||||
|
||||
**Scenario 3: Column shows 7**
|
||||
|
||||
- Column state: Heaven bead is down (5), 2 earth beads are up (5+2=7)
|
||||
- To add 4: Result would be 11—overflows the column!
|
||||
- Operation: Add 10 to the next column (carry), subtract 6 from this column
|
||||
- **Skill exercised**: `tenComplements.4=10-6`
|
||||
|
||||
The same term "+4" requires completely different finger movements and visual patterns depending on the abacus state. A student who has automated `basic.directAddition` might still struggle with `tenComplements.4=10-6`—these are distinct patterns that must be drilled separately.
|
||||
|
||||
## The Soroban Pattern Hierarchy
|
||||
|
||||
Soroban curricula organize patterns into a strict progression, where each level must be mastered before advancing. We model this as approximately 30 distinct patterns:
|
||||
|
||||
### Basic Patterns (Complexity 0)
|
||||
|
||||
Direct bead manipulations—the foundation that must be automatic before advancing:
|
||||
|
||||
- `basic.directAddition` — Push 1-4 earth beads up
|
||||
- `basic.directSubtraction` — Pull 1-4 earth beads down
|
||||
- `basic.heavenBead` — Lower the heaven bead (add 5)
|
||||
- `basic.heavenBeadSubtraction` — Raise the heaven bead (subtract 5)
|
||||
- `basic.simpleCombinations` — Add 6-9 using earth + heaven beads together
|
||||
|
||||
### Five-Complement Patterns (Complexity 1)
|
||||
|
||||
Single-column patterns involving the heaven bead threshold—introduced only after basic patterns are automatic:
|
||||
|
||||
- `fiveComplements.4=5-1` — "Add 4" becomes "add 5, subtract 1"
|
||||
- `fiveComplements.3=5-2` — "Add 3" becomes "add 5, subtract 2"
|
||||
- `fiveComplements.2=5-3` — "Add 2" becomes "add 5, subtract 3"
|
||||
- `fiveComplements.1=5-4` — "Add 1" becomes "add 5, subtract 4"
|
||||
|
||||
And the corresponding subtraction variants (`fiveComplementsSub.*`).
|
||||
|
||||
### Ten-Complement Patterns (Complexity 2)
|
||||
|
||||
Multi-column patterns involving carries and borrows—the final major category:
|
||||
|
||||
- `tenComplements.9=10-1` — "Add 9" becomes "carry 10, subtract 1"
|
||||
- `tenComplements.8=10-2` — "Add 8" becomes "carry 10, subtract 2"
|
||||
- ... through `tenComplements.1=10-9`
|
||||
|
||||
And the corresponding subtraction variants (`tenComplementsSub.*`).
|
||||
|
||||
### Mixed/Advanced Patterns (Complexity 3)
|
||||
|
||||
Cascading operations where carries or borrows propagate across multiple columns (e.g., 999 + 1 = 1000).
|
||||
|
||||
## Simulation-Based Pattern Tagging
|
||||
|
||||
At problem-generation time, we simulate the abacus to determine which patterns each term will exercise. This is more precise than tagging at the problem-type level (e.g., "all +4 problems use skill X")—we tag at the problem-instance level based on the actual column states encountered.
|
||||
|
||||
```
|
||||
Problem: 7 + 4 + 2 = 13
|
||||
|
||||
Step 1: Start with 0, add 7
|
||||
Column state: ones=0 → ones=7
|
||||
Analysis: Adding 6-9 requires moving both heaven bead and earth beads together
|
||||
Patterns: [basic.simpleCombinations]
|
||||
|
||||
Step 2: From 7, add 4
|
||||
Column state: ones=7 → overflow!
|
||||
Analysis: 7 + 4 = 11, exceeds column capacity (max 9)
|
||||
Rule: Ten-complement (+10, -6)
|
||||
Patterns: [tenComplements.4=10-6]
|
||||
|
||||
Step 3: From 11 (ones=1, tens=1), add 2
|
||||
Column state: ones=1 → ones=3
|
||||
Analysis: Only 1 earth bead is up; room to push 2 more
|
||||
Patterns: [basic.directAddition]
|
||||
|
||||
Total patterns exercised: [basic.simpleCombinations, basic.directAddition, tenComplements.4=10-6]
|
||||
```
|
||||
|
||||
This simulation happens at problem-generation time. The generated problem carries its pattern tags explicitly—static once generated, but computed precisely for this specific problem instance:
|
||||
|
||||
```typescript
|
||||
interface GeneratedProblem {
|
||||
terms: number[]; // [7, 4, 2]
|
||||
answer: number; // 13
|
||||
patternsExercised: string[]; // ['basic.simpleCombinations', 'basic.directAddition', 'tenComplements.4=10-6']
|
||||
}
|
||||
```
|
||||
|
||||
## The Inference Challenge
|
||||
|
||||
Now consider what happens when the student solves this problem:
|
||||
|
||||
**Observation**: Student answered **incorrectly**.
|
||||
|
||||
**Patterns involved**: `basic.simpleCombinations`, `basic.directAddition`, `tenComplements.4=10-6`
|
||||
|
||||
**The question**: Which pattern failed?
|
||||
|
||||
We have three possibilities:
|
||||
|
||||
1. The student made an error on the simple combination (adding 7)
|
||||
2. The student made an error on the direct addition (adding 2)
|
||||
3. The student made an error on the ten-complement operation (adding 4 via carry)
|
||||
|
||||
But we can't know for certain. All we observe is the binary outcome.
|
||||
|
||||
### Asymmetric Evidence
|
||||
|
||||
Here's a crucial insight:
|
||||
|
||||
**If the student answers correctly**, we have strong evidence that **all** patterns were executed successfully. You can't get the right answer if any pattern fails.
|
||||
|
||||
**If the student answers incorrectly**, we only know that **at least one** pattern failed. We don't know which one(s).
|
||||
|
||||
This asymmetry is fundamental to our inference approach.
|
||||
|
||||
## Conjunctive Bayesian Knowledge Tracing
|
||||
|
||||
Standard BKT (Bayesian Knowledge Tracing) models a single skill as a hidden Markov model:
|
||||
|
||||
- Hidden state: Does the student know the skill? (binary)
|
||||
- Observation: Did the student answer correctly? (binary)
|
||||
- Parameters: P(L₀) initial knowledge, P(T) learning rate, P(S) slip rate, P(G) guess rate
|
||||
|
||||
The update equations use Bayes' theorem:
|
||||
|
||||
```
|
||||
P(known | correct) = P(correct | known) × P(known) / P(correct)
|
||||
= (1 - P(slip)) × P(known) / P(correct)
|
||||
|
||||
P(known | incorrect) = P(incorrect | known) × P(known) / P(incorrect)
|
||||
= P(slip) × P(known) / P(incorrect)
|
||||
```
|
||||
|
||||
### Extension to Multi-Pattern Problems
|
||||
|
||||
For problems involving multiple patterns, we extend BKT with a **conjunctive model**:
|
||||
|
||||
**On a correct answer**: All patterns receive positive evidence. We update each pattern independently using the standard BKT correct-answer update.
|
||||
|
||||
**On an incorrect answer**: We distribute "blame" probabilistically. Patterns that the student is less likely to have automated receive more of the blame.
|
||||
|
||||
The blame distribution formula:
|
||||
|
||||
```
|
||||
blame(pattern) ∝ (1 - P(known_pattern))
|
||||
```
|
||||
|
||||
A pattern with P(known) = 0.3 gets more blame than a pattern with P(known) = 0.9. This is intuitive: if a student has demonstrated automaticity of a pattern many times, an error is less likely to be caused by that pattern.
|
||||
|
||||
### The Blame-Weighted Update
|
||||
|
||||
For each pattern in an incorrect multi-pattern problem:
|
||||
|
||||
```typescript
|
||||
// Calculate blame weights
|
||||
const totalUnknown = patterns.reduce((sum, p) => sum + (1 - p.pKnown), 0);
|
||||
const blameWeight = (1 - pattern.pKnown) / totalUnknown;
|
||||
|
||||
// Calculate what the full negative update would be
|
||||
const fullNegativeUpdate = bktUpdate(pattern.pKnown, false, params);
|
||||
|
||||
// Apply a weighted blend: more blame → more negative update
|
||||
const newPKnown =
|
||||
pattern.pKnown * (1 - blameWeight) + fullNegativeUpdate * blameWeight;
|
||||
```
|
||||
|
||||
This creates a soft attribution: patterns that likely caused the error receive stronger negative evidence, while patterns that are probably automated receive only weak negative evidence.
|
||||
|
||||
### Edge Case: All Patterns Automated
|
||||
|
||||
What if all patterns have high P(known)? Then the error is probably a **slip** (random error despite knowledge), and we distribute blame evenly:
|
||||
|
||||
```typescript
|
||||
if (totalUnknown < 0.001) {
|
||||
// All patterns appear automated — must be a slip
|
||||
const evenWeight = 1 / patterns.length;
|
||||
// Apply full negative update with even distribution
|
||||
}
|
||||
```
|
||||
|
||||
### Methodological Note: Heuristic vs. True Bayesian Inference
|
||||
|
||||
The blame distribution formula above is a **heuristic approximation**, not proper Bayesian inference. True conjunctive BKT would compute the posterior probability that each skill is unknown given the failure:
|
||||
|
||||
```
|
||||
P(¬known_i | fail) = P(fail ∧ ¬known_i) / P(fail)
|
||||
```
|
||||
|
||||
This requires marginalizing over all 2^n possible knowledge states—computationally tractable for n ≤ 6 skills (our typical case), but more complex to implement.
|
||||
|
||||
We validated both approaches using our journey simulator across 5 random seeds and 3 learner profiles:
|
||||
|
||||
| Method | Mean BKT-Truth Correlation | Wins |
|
||||
| ------------------ | -------------------------- | ---- |
|
||||
| Heuristic (linear) | 0.394 | 3/5 |
|
||||
| Bayesian (exact) | 0.356 | 2/5 |
|
||||
| **t-test** | t = -0.41, **p > 0.05** | |
|
||||
|
||||
<!-- CHART: BlameAttribution -->
|
||||
|
||||
**Result**: No statistically significant difference. The heuristic's softer blame attribution appears equally effective—possibly more robust to the noise inherent in learning dynamics.
|
||||
|
||||
We retain the Bayesian implementation for reproducibility and potential future research ([source code](https://github.com/antialias/soroban-abacus-flashcards/blob/main/apps/web/src/lib/curriculum/bkt/conjunctive-bkt.ts)), but the production system uses the simpler heuristic. Full validation data is available in our [blame attribution test suite](https://github.com/antialias/soroban-abacus-flashcards/blob/main/apps/web/src/test/journey-simulator/blame-attribution.test.ts).
|
||||
|
||||
## Evidence Quality Modifiers
|
||||
|
||||
Not all observations are equally informative. We weight the evidence based on help level and response time.
|
||||
|
||||
<!-- CHART: EvidenceQuality -->
|
||||
|
||||
## Automaticity-Aware Problem Generation
|
||||
|
||||
Problem generation involves two concerns:
|
||||
|
||||
1. **Skill targeting** (BKT-based): Identifies which skills need practice and prioritizes them
|
||||
2. **Cost calculation**: Controls problem difficulty by budgeting cognitive load
|
||||
|
||||
Both concerns now use BKT. We experimented with separating them—using BKT only for targeting while using fluency (recent streak consistency) for cost calculation—but found that using BKT for both produces equivalent results while simplifying the architecture.
|
||||
|
||||
### Complexity Budgeting
|
||||
|
||||
We budget problem complexity based on the student's estimated mastery from BKT. When BKT confidence is low (< 30%), we fall back to fluency-based estimates.
|
||||
|
||||
### Complexity Costing
|
||||
|
||||
Each pattern has a **base complexity cost**:
|
||||
|
||||
- Basic patterns: 0 (trivial)
|
||||
- Five-complement patterns: 1 (one mental decomposition)
|
||||
- Ten-complement patterns: 2 (cross-column operation)
|
||||
- Mixed/cascading: 3 (multi-column propagation)
|
||||
|
||||
### Automaticity Multipliers
|
||||
|
||||
The cost is scaled by the student's estimated mastery from BKT. The multiplier uses a non-linear (squared) mapping from P(known) to provide better differentiation at high mastery levels. When BKT confidence is insufficient (< 30%), we fall back to discrete fluency states based on recent streaks.
|
||||
|
||||
<!-- CHART: AutomaticityMultipliers -->
|
||||
|
||||
### Adaptive Session Planning
|
||||
|
||||
A practice session has a **complexity budget**. The problem generator:
|
||||
|
||||
1. Selects terms that exercise the target patterns for the current curriculum phase
|
||||
2. Simulates the problem to extract actual patterns exercised
|
||||
3. Calculates total complexity: Σ(base_cost × automaticity_multiplier) for each pattern
|
||||
4. Accepts the problem only if it fits the session's complexity budget
|
||||
|
||||
This creates natural adaptation:
|
||||
|
||||
- A student who has automated ten-complements gets harder problems (their multiplier is low)
|
||||
- A student still learning ten-complements gets simpler problems (their multiplier is high)
|
||||
|
||||
```typescript
|
||||
// Same problem, different complexity for different students:
|
||||
const problem = [7, 6] // 7 + 6 = 13, requires tenComplements.6
|
||||
|
||||
// Student A: BKT P(known) = 0.95 for ten-complements
|
||||
complexity_A = 2 × 1.3 = 2.6 // Easy for this student
|
||||
|
||||
// Student B: BKT P(known) = 0.50 for ten-complements
|
||||
complexity_B = 2 × 3.3 = 6.6 // Challenging for this student
|
||||
```
|
||||
|
||||
## Adaptive Skill Targeting
|
||||
|
||||
Beyond controlling difficulty, BKT identifies _which skills need practice_.
|
||||
|
||||
### Identifying Weak Skills
|
||||
|
||||
When planning a practice session, we analyze BKT results to find skills that are:
|
||||
|
||||
- **Confident**: The model has enough data (confidence ≥ 30%)
|
||||
- **Weak**: The estimated P(known) is below threshold (< 50%)
|
||||
|
||||
```typescript
|
||||
function identifyWeakSkills(bktResults: Map<string, BktResult>): string[] {
|
||||
const weakSkills: string[] = [];
|
||||
for (const [skillId, result] of bktResults) {
|
||||
if (result.confidence >= 0.3 && result.pKnown < 0.5) {
|
||||
weakSkills.push(skillId);
|
||||
}
|
||||
}
|
||||
return weakSkills;
|
||||
}
|
||||
```
|
||||
|
||||
The confidence threshold prevents acting on insufficient data. A skill practiced only twice might show low P(known), but we don't have enough evidence to trust that estimate.
|
||||
|
||||
### Targeting Weak Skills in Problem Generation
|
||||
|
||||
Identified weak skills are added to the problem generator's `targetSkills` constraint. This biases problem generation toward exercises that include the weak pattern—not by making problems easier, but by ensuring the student gets practice on what they need.
|
||||
|
||||
```typescript
|
||||
// In session planning:
|
||||
const weakSkills = identifyWeakSkills(bktResults);
|
||||
|
||||
// Add weak skills to focus slot targets
|
||||
for (const slot of focusSlots) {
|
||||
slot.targetSkills = [...slot.targetSkills, ...weakSkills];
|
||||
}
|
||||
```
|
||||
|
||||
### The Budget Trap (and How We Avoided It)
|
||||
|
||||
When we first tried using BKT P(known) as a cost multiplier, we hit a problem: skills with low P(known) got high multipliers, making them expensive. If we only used cost filtering, the budget would exclude weak skills—students would never practice what they needed most.
|
||||
|
||||
The solution was **skill targeting**: BKT identifies weak skills and adds them to the problem generator's required targets. This ensures weak skills appear in problems _regardless_ of their cost. The complexity budget still applies, but it filters problem _structure_ (number of terms, digit ranges), not which skills can appear.
|
||||
|
||||
A student struggling with ten-complements gets problems that _include_ ten-complements (targeting), while the problem complexity stays within their budget (fewer terms, simpler starting values).
|
||||
|
||||
## Honest Uncertainty Reporting
|
||||
|
||||
Our system explicitly tracks and reports confidence alongside skill estimates.
|
||||
|
||||
### Confidence Calculation
|
||||
|
||||
Confidence increases with more data and more consistent observations:
|
||||
|
||||
```typescript
|
||||
function calculateConfidence(
|
||||
opportunities: number,
|
||||
successRate: number,
|
||||
): number {
|
||||
// More data → more confidence (asymptotic to 1)
|
||||
const dataConfidence = 1 - Math.exp(-opportunities / 20);
|
||||
|
||||
// Extreme success rates → more confidence
|
||||
const extremity = Math.abs(successRate - 0.5) * 2;
|
||||
const consistencyBonus = extremity * 0.2;
|
||||
|
||||
return Math.min(1, dataConfidence + consistencyBonus);
|
||||
}
|
||||
```
|
||||
|
||||
With 10 opportunities, we're ~40% confident. With 50 opportunities, we're ~92% confident.
|
||||
|
||||
### Uncertainty Ranges
|
||||
|
||||
We display P(known) with an uncertainty range that widens as confidence decreases:
|
||||
|
||||
```
|
||||
Pattern: tenComplements.4=10-6
|
||||
Estimated automaticity: ~73%
|
||||
Confidence: moderate
|
||||
Range: 58% - 88%
|
||||
```
|
||||
|
||||
This honest framing prevents over-claiming. A "73% automaticity" with low confidence is very different from "73% automaticity" with high confidence.
|
||||
|
||||
### Staleness Indicators
|
||||
|
||||
We track when each pattern was last practiced and display warnings:
|
||||
|
||||
| Days Since Practice | Warning |
|
||||
| ------------------- | ------------------------------ |
|
||||
| < 7 | (none) |
|
||||
| 7-14 | "Not practiced recently" |
|
||||
| 14-30 | "Getting rusty" |
|
||||
| > 30 | "Very stale — may need review" |
|
||||
|
||||
Importantly, we show staleness as a **separate indicator**, not by decaying P(known). The student might still have the pattern automated; we just haven't observed it recently.
|
||||
|
||||
## Architecture: Lazy Computation
|
||||
|
||||
A key architectural decision: we don't store BKT state persistently. Instead, we:
|
||||
|
||||
1. Store raw problem results (correct/incorrect, timestamp, response time, help level)
|
||||
2. Compute BKT on-demand when viewing the skills dashboard
|
||||
3. Replay history chronologically to build up current P(known) estimates
|
||||
|
||||
This has several advantages:
|
||||
|
||||
- No database migrations when we tune BKT parameters
|
||||
- Can experiment with different algorithms without data loss
|
||||
- User controls (confidence threshold slider) work instantly
|
||||
- Estimated computation time: ~50ms for a full dashboard with 100+ problems
|
||||
|
||||
## Automaticity Classification
|
||||
|
||||
Once we have a P(known) estimate with sufficient confidence, we classify each skill into one of three zones:
|
||||
|
||||
- **Struggling** (P(known) < 50%): The student likely hasn't internalized this pattern yet. Problems using this skill will feel difficult and error-prone.
|
||||
- **Learning** (P(known) 50-80%): The student is developing competence but hasn't achieved automaticity. They can usually get it right but need to think about it.
|
||||
- **Automated** (P(known) > 80%): The pattern is internalized. The student can apply it quickly and reliably without conscious effort.
|
||||
|
||||
The confidence threshold is user-adjustable (default 50%), allowing teachers to be more or less strict about what counts as "confident enough to classify." Skills with insufficient data remain in "Learning" until more evidence accumulates.
|
||||
|
||||
<!-- CHART: Classification -->
|
||||
|
||||
## Skill-Specific Difficulty Model
|
||||
|
||||
Not all soroban patterns are equally difficult to master. Our student simulation model incorporates **skill-specific difficulty multipliers** based on pedagogical observation:
|
||||
|
||||
- **Basic skills** (direct bead manipulation): Easiest to master, multiplier 0.8-0.9x
|
||||
- **Five-complements** (single-column decomposition): Moderate difficulty, multiplier 1.2-1.3x
|
||||
- **Ten-complements** (cross-column carrying): Hardest, multiplier 1.6-2.1x
|
||||
|
||||
These multipliers affect the Hill function's K parameter (the exposure count where P(correct) = 50%). A skill with multiplier 2.0x requires twice as many practice exposures to reach the same mastery level.
|
||||
|
||||
The interactive charts below show how these difficulty multipliers affect learning trajectories. Data is derived from validated simulation tests ([source code](https://github.com/antialias/soroban-abacus-flashcards/blob/main/apps/web/src/test/journey-simulator/skill-difficulty.test.ts)).
|
||||
|
||||
<!-- CHART: SkillDifficulty -->
|
||||
|
||||
## Validation: Does Adaptive Targeting Actually Work?
|
||||
|
||||
We built a journey simulator to compare three modes across controlled scenarios:
|
||||
|
||||
- **Classic**: Uniform skill distribution, fluency-based difficulty
|
||||
- **Adaptive (fluency)**: BKT skill targeting, fluency-based difficulty
|
||||
- **Adaptive (full BKT)**: BKT skill targeting, BKT-based difficulty
|
||||
|
||||
### Simulation Framework
|
||||
|
||||
The simulator models student learning using:
|
||||
|
||||
- **Hill function learning model**: `P(correct) = exposure^n / (K^n + exposure^n)`, where exposure is the number of times the student has practiced a skill
|
||||
- **Conjunctive model**: Multi-skill problems require all skills to succeed—P(correct) is the product of individual skill probabilities
|
||||
- **Per-skill deficiency profiles**: Each test case starts one skill at zero exposure, with all prerequisites mastered
|
||||
- **Cognitive fatigue tracking**: Sum of difficulty multipliers for each skill in each problem—measures the mental effort required per session
|
||||
|
||||
The Hill function creates realistic learning curves: early practice yields slow improvement (building foundation), then understanding "clicks" (rapid gains), then asymptotic approach to mastery.
|
||||
|
||||
### The Measurement Challenge
|
||||
|
||||
Our first validation attempt measured overall problem accuracy—but this penalized adaptive mode for doing its job. When adaptive generates problems targeting weak skills, those problems have lower P(correct) by design.
|
||||
|
||||
The solution: **per-skill assessment without learning**. After practice sessions, we assess each student's mastery of the originally-deficient skill using trials that don't increment exposure. This measures true mastery independent of problem selection effects.
|
||||
|
||||
```typescript
|
||||
// Assessment that doesn't pollute learning state
|
||||
assessSkill(skillId: string, trials: number = 20): SkillAssessment {
|
||||
const trueProbability = this.getTrueProbability(skillId)
|
||||
// Run trials WITHOUT incrementing exposure
|
||||
let correct = 0
|
||||
for (let i = 0; i < trials; i++) {
|
||||
if (this.rng.chance(trueProbability)) correct++
|
||||
}
|
||||
return { skillId, trueProbability, assessedAccuracy: correct / trials }
|
||||
}
|
||||
```
|
||||
|
||||
### Convergence Speed Results
|
||||
|
||||
The key question: How fast does each mode bring a weak skill to mastery? The data below is generated from our journey simulator test suite ([source code](https://github.com/antialias/soroban-abacus-flashcards/blob/main/apps/web/src/test/journey-simulator/journey-simulator.test.ts)).
|
||||
|
||||
<!-- CHART: ValidationResults -->
|
||||
|
||||
### 3-Way Comparison: BKT vs Fluency Multipliers
|
||||
|
||||
We also compared whether using BKT for cost calculation (in addition to targeting) provides additional benefit over fluency-based cost calculation.
|
||||
|
||||
<!-- CHART: ThreeWayComparison -->
|
||||
|
||||
### Why Adaptive Wins
|
||||
|
||||
The mechanism is straightforward:
|
||||
|
||||
1. BKT identifies skills with low P(known) and sufficient confidence
|
||||
2. These skills are added to `targetSkills` in problem generation
|
||||
3. The student gets more exposure to weak skills
|
||||
4. More exposure → faster mastery (via Hill function)
|
||||
|
||||
In our simulations, adaptive mode provided ~5% more exposure to deficient skills on average. This modest increase compounds across sessions into significant mastery differences.
|
||||
|
||||
### Remaining Research Questions
|
||||
|
||||
1. **Real-world validation**: Do simulated results hold with actual students?
|
||||
2. **Optimal thresholds**: Are P(known) < 0.5 and confidence ≥ 0.3 the right cutoffs?
|
||||
3. **Targeting aggressiveness**: Should we weight weak skills more heavily in generation?
|
||||
4. **Cross-student priors**: Can aggregate data improve initial estimates for new students?
|
||||
|
||||
If you're interested in the educational data mining aspects of this work, [reach out](mailto:contact@abaci.one).
|
||||
|
||||
## Limitations
|
||||
|
||||
### Simulation-Only Validation
|
||||
|
||||
The validation results reported here are derived entirely from **simulated students**, not human learners. Our simulator assumes:
|
||||
|
||||
- **Hill function learning curves**: Mastery probability increases with exposure according to `P = exposure^n / (K^n + exposure^n)`. Real students may exhibit plateau effects, regression, or non-monotonic learning.
|
||||
- **Probabilistic slips**: Errors on mastered skills are random with fixed probability. Real errors may reflect systematic misconceptions that BKT handles poorly.
|
||||
- **Independent skill application**: The conjunctive model assumes each skill is applied independently within a problem.
|
||||
|
||||
The "25-33% faster mastery" finding should be interpreted as: _given students who learn according to our model assumptions, adaptive targeting accelerates simulated progress_. Whether this transfers to human learners remains an open empirical question.
|
||||
|
||||
### The Technique Bypass Problem
|
||||
|
||||
BKT infers skill mastery from answer correctness, but correct answers don't guarantee proper technique. A student might:
|
||||
|
||||
- Use mental arithmetic instead of bead manipulation
|
||||
- Count on fingers rather than applying complement rules
|
||||
- Arrive at correct answers through inefficient multi-step processes
|
||||
|
||||
Our system cannot distinguish "correct via proper abacus technique" from "correct via alternative method." This is partially mitigated by:
|
||||
|
||||
- **Response time**: Properly automated technique should be faster than mental workarounds
|
||||
- **Visualization mode**: When students use the on-screen abacus, we observe their actual bead movements
|
||||
- **Pattern complexity**: Higher-digit problems are harder to solve via mental math, making technique bypass less viable
|
||||
|
||||
Definitive detection of technique usage would require video analysis or teacher observation—areas for future integration.
|
||||
|
||||
### Independent Failure Assumption
|
||||
|
||||
The blame attribution formula treats skill failures as independent parallel events:
|
||||
|
||||
```
|
||||
blame(skill_i) ∝ (1 - P(known_i))
|
||||
```
|
||||
|
||||
In reality, foundational skill failures may trigger cognitive cascades. If a student fails `basic.directAddition`, they may become confused and subsequently fail `fiveComplements` even if they "know" it. Our model cannot distinguish:
|
||||
|
||||
- "Failed because didn't know the complement rule"
|
||||
- "Failed because earlier confusion disrupted working memory"
|
||||
|
||||
This is a known limitation of standard BKT. More sophisticated models (e.g., Deep Knowledge Tracing, or models with prerequisite dependencies) could potentially capture these effects, at the cost of interpretability and sample efficiency.
|
||||
|
||||
## Why We Built This (And What's Next)
|
||||
|
||||
This research was conducted to validate the core idea of **skill-targeted problem generation** before deploying it in [abaci.one](https://abaci.one)—an automatic proctoring system designed to run soroban practice sessions without requiring constant teacher supervision.
|
||||
|
||||
The simulation results gave us confidence that the approach is sound in principle. We've now deployed these algorithms in the live system, which is designed to collect detailed data from every practice session:
|
||||
|
||||
- Problem-by-problem response times and correctness
|
||||
- Help usage patterns (hints, decomposition views, full solutions)
|
||||
- Skill exposure sequences and mastery trajectories
|
||||
- Session-level fatigue and engagement indicators
|
||||
|
||||
**We plan to publish a follow-up analysis** once we've collected sufficient data from real students. This will let us answer the questions our simulator cannot:
|
||||
|
||||
- Do real students learn according to Hill-like curves, or something else?
|
||||
- Does adaptive targeting actually accelerate mastery in practice?
|
||||
- How accurate are our BKT estimates compared to teacher assessments?
|
||||
- What failure modes emerge that our simulation didn't anticipate?
|
||||
|
||||
Until then, the claims in this post should be understood as _validated in simulation, pending real-world confirmation_.
|
||||
|
||||
## Summary
|
||||
|
||||
Building an intelligent tutoring system for soroban arithmetic required solving a fundamental inference problem: how do you know which pattern failed when you only observe binary problem outcomes?
|
||||
|
||||
Our approach combines:
|
||||
|
||||
1. **Simulation-based pattern tagging** at problem-generation time
|
||||
2. **Conjunctive BKT** with probabilistic blame distribution
|
||||
3. **Evidence quality weighting** based on help level and response time
|
||||
4. **Unified BKT architecture**: BKT drives both difficulty adjustment and skill targeting
|
||||
5. **Honest uncertainty reporting** with confidence intervals
|
||||
6. **Simulation-validated adaptive targeting** that may reach mastery 25-33% faster than uniform practice (pending real-world confirmation)
|
||||
|
||||
The key insight from our simulation studies: the benefit of adaptive practice comes from _targeting weak skills_, not from the specific formula used for difficulty adjustment. BKT targeting ensures students practice what they need; the complexity budget ensures they're not overwhelmed.
|
||||
|
||||
The result is a system that adapts to each student's actual pattern automaticity, not just their overall accuracy—focusing practice where it matters most while honestly communicating what it knows and doesn't know.
|
||||
|
||||
---
|
||||
|
||||
_This post describes the pattern tracing system built into [abaci.one](https://abaci.one), a free soroban practice application. The full source code is available on [GitHub](https://github.com/antialias/soroban-abacus-flashcards)._
|
||||
|
||||
## References
|
||||
|
||||
- Corbett, A. T., & Anderson, J. R. (1994). Knowledge tracing: Modeling the acquisition of procedural knowledge. _User Modeling and User-Adapted Interaction_, 4(4), 253-278.
|
||||
|
||||
- Pardos, Z. A., & Heffernan, N. T. (2011). KT-IDEM: Introducing item difficulty to the knowledge tracing model. In _International Conference on User Modeling, Adaptation, and Personalization_ (pp. 243-254). Springer.
|
||||
|
||||
- Baker, R. S., Corbett, A. T., & Aleven, V. (2008). More accurate student modeling through contextual estimation of slip and guess probabilities in Bayesian knowledge tracing. In _International Conference on Intelligent Tutoring Systems_ (pp. 406-415). Springer.
|
||||
@@ -16,6 +16,7 @@ Operations that don't require carrying/borrowing across columns.
|
||||
|
||||
**Addition (+1 through +9)**
|
||||
For each number, practice in this order:
|
||||
|
||||
1. **Without friends of 5**: Direct bead movements only
|
||||
- e.g., `2 + 1 = 3` (just move earth beads)
|
||||
2. **With friends of 5**: Using the 5-complement technique
|
||||
@@ -23,6 +24,7 @@ For each number, practice in this order:
|
||||
|
||||
**Subtraction (-9 through -1)**
|
||||
For each number, practice in this order:
|
||||
|
||||
1. **Without friends of 5**: Direct bead movements only
|
||||
- e.g., `7 - 2 = 5` (just remove earth beads)
|
||||
2. **With friends of 5**: Using the 5-complement technique
|
||||
@@ -34,6 +36,7 @@ Addition that requires carrying to the next column.
|
||||
|
||||
**Addition (+1 through +9)**
|
||||
For each number:
|
||||
|
||||
1. **Without friends of 5**: Pure 10-complement
|
||||
- e.g., `5 + 7 = 12` → needs `-3, +10` (no 5-bead manipulation in ones)
|
||||
2. **With friends of 5**: Combined 10-complement and 5-complement
|
||||
@@ -45,6 +48,7 @@ Subtraction that requires borrowing from the next column.
|
||||
|
||||
**Subtraction (-9 through -1)**
|
||||
For each number:
|
||||
|
||||
1. **Without friends of 5**: Pure 10-complement
|
||||
- e.g., `12 - 7 = 5` → needs `+3, -10`
|
||||
2. **With friends of 5**: Combined 10-complement and 5-complement
|
||||
@@ -60,26 +64,26 @@ For each number:
|
||||
|
||||
### What We Have
|
||||
|
||||
| Component | Location | Can Leverage |
|
||||
|-----------|----------|--------------|
|
||||
| Problem generator | `src/utils/problemGenerator.ts` | ✅ Core logic exists |
|
||||
| Skill analysis | `analyzeColumnAddition()` | ✅ Pattern to follow |
|
||||
| SkillSet types | `src/types/tutorial.ts` | ✅ Has 5/10 complements |
|
||||
| Practice player | `src/components/tutorial/PracticeProblemPlayer.tsx` | ✅ UI exists |
|
||||
| Constraint system | `requiredSkills`, `targetSkills`, `forbiddenSkills` | ✅ Ready to use |
|
||||
| Component | Location | Can Leverage |
|
||||
| ----------------- | --------------------------------------------------- | ----------------------- |
|
||||
| Problem generator | `src/utils/problemGenerator.ts` | ✅ Core logic exists |
|
||||
| Skill analysis | `analyzeColumnAddition()` | ✅ Pattern to follow |
|
||||
| SkillSet types | `src/types/tutorial.ts` | ✅ Has 5/10 complements |
|
||||
| Practice player | `src/components/tutorial/PracticeProblemPlayer.tsx` | ✅ UI exists |
|
||||
| Constraint system | `allowedSkills`, `targetSkills`, `forbiddenSkills` | ✅ Ready to use |
|
||||
|
||||
### What We Need to Add
|
||||
|
||||
| Feature | Description | File(s) to Modify | Status |
|
||||
|---------|-------------|-------------------|--------|
|
||||
| Subtraction skill analysis | `analyzeColumnSubtraction()` | `src/utils/problemGenerator.ts` | ✅ Done |
|
||||
| Subtraction in SkillSet | Add subtraction-specific skills | `src/types/tutorial.ts` | ✅ Done |
|
||||
| Curriculum definitions | Level 1/2/3 PracticeStep configs | New: `src/curriculum/` | ⏳ Pending |
|
||||
| Visualization mode | Hide abacus option | `PracticeProblemPlayer.tsx` | ⏳ Pending |
|
||||
| Adaptive mastery | Continue until N consecutive correct | New logic | ⏳ Pending |
|
||||
| Progress persistence | Track technique mastery | Database/localStorage | ⏳ Pending |
|
||||
| **Student profiles** | Extend players with curriculum progress | New DB tables | ✅ Done |
|
||||
| **Student selection UI** | Pick student before practice | `src/components/practice/` | ✅ Done |
|
||||
| Feature | Description | File(s) to Modify | Status |
|
||||
| -------------------------- | --------------------------------------- | ------------------------------- | ---------- |
|
||||
| Subtraction skill analysis | `analyzeColumnSubtraction()` | `src/utils/problemGenerator.ts` | ✅ Done |
|
||||
| Subtraction in SkillSet | Add subtraction-specific skills | `src/types/tutorial.ts` | ✅ Done |
|
||||
| Curriculum definitions | Level 1/2/3 PracticeStep configs | New: `src/curriculum/` | ⏳ Pending |
|
||||
| Visualization mode | Hide abacus option | `PracticeProblemPlayer.tsx` | ⏳ Pending |
|
||||
| Adaptive mastery | Continue until N consecutive correct | New logic | ⏳ Pending |
|
||||
| Progress persistence | Track technique mastery | Database/localStorage | ⏳ Pending |
|
||||
| **Student profiles** | Extend players with curriculum progress | New DB tables | ✅ Done |
|
||||
| **Student selection UI** | Pick student before practice | `src/components/practice/` | ✅ Done |
|
||||
|
||||
## Student Progress Architecture
|
||||
|
||||
@@ -135,38 +139,38 @@ This means a child's avatar in arcade games is the same avatar they use for prac
|
||||
```typescript
|
||||
// player_curriculum - Overall curriculum position for a player
|
||||
interface PlayerCurriculum {
|
||||
playerId: string // FK to players, PRIMARY KEY
|
||||
currentLevel: 1 | 2 | 3 // Which level they're on
|
||||
currentPhaseId: string // e.g., "L1.add.+3.withFive"
|
||||
worksheetPreset: string // Saved worksheet difficulty profile
|
||||
visualizationMode: boolean // Practice without visible abacus
|
||||
updatedAt: Date
|
||||
playerId: string; // FK to players, PRIMARY KEY
|
||||
currentLevel: 1 | 2 | 3; // Which level they're on
|
||||
currentPhaseId: string; // e.g., "L1.add.+3.withFive"
|
||||
worksheetPreset: string; // Saved worksheet difficulty profile
|
||||
visualizationMode: boolean; // Practice without visible abacus
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// player_skill_mastery - Per-skill progress tracking
|
||||
interface PlayerSkillMastery {
|
||||
id: string
|
||||
playerId: string // FK to players
|
||||
skillId: string // e.g., "fiveComplements.4=5-1"
|
||||
attempts: number // Total attempts using this skill
|
||||
correct: number // Successful uses
|
||||
consecutiveCorrect: number // Current streak (resets on error)
|
||||
masteryLevel: 'learning' | 'practicing' | 'mastered'
|
||||
lastPracticedAt: Date
|
||||
id: string;
|
||||
playerId: string; // FK to players
|
||||
skillId: string; // e.g., "fiveComplements.4=5-1"
|
||||
attempts: number; // Total attempts using this skill
|
||||
correct: number; // Successful uses
|
||||
consecutiveCorrect: number; // Current streak (resets on error)
|
||||
masteryLevel: "learning" | "practicing" | "mastered";
|
||||
lastPracticedAt: Date;
|
||||
// UNIQUE constraint on (playerId, skillId)
|
||||
}
|
||||
|
||||
// practice_sessions - Historical session data
|
||||
interface PracticeSession {
|
||||
id: string
|
||||
playerId: string
|
||||
phaseId: string // Which curriculum phase
|
||||
problemsAttempted: number
|
||||
problemsCorrect: number
|
||||
averageTimeMs: number
|
||||
skillsUsed: string[] // Skills exercised this session
|
||||
startedAt: Date
|
||||
completedAt: Date
|
||||
id: string;
|
||||
playerId: string;
|
||||
phaseId: string; // Which curriculum phase
|
||||
problemsAttempted: number;
|
||||
problemsCorrect: number;
|
||||
averageTimeMs: number;
|
||||
skillsUsed: string[]; // Skills exercised this session
|
||||
startedAt: Date;
|
||||
completedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -174,21 +178,23 @@ interface PracticeSession {
|
||||
|
||||
```typescript
|
||||
const MASTERY_CONFIG = {
|
||||
consecutiveForMastery: 5, // 5 correct in a row = mastered
|
||||
minimumAttempts: 10, // Need at least 10 attempts
|
||||
accuracyThreshold: 0.85, // 85% accuracy for practicing → mastered
|
||||
}
|
||||
consecutiveForMastery: 5, // 5 correct in a row = mastered
|
||||
minimumAttempts: 10, // Need at least 10 attempts
|
||||
accuracyThreshold: 0.85, // 85% accuracy for practicing → mastered
|
||||
};
|
||||
|
||||
function updateMasteryLevel(skill: PlayerSkillMastery): MasteryLevel {
|
||||
if (skill.consecutiveCorrect >= MASTERY_CONFIG.consecutiveForMastery
|
||||
&& skill.attempts >= MASTERY_CONFIG.minimumAttempts
|
||||
&& (skill.correct / skill.attempts) >= MASTERY_CONFIG.accuracyThreshold) {
|
||||
return 'mastered'
|
||||
if (
|
||||
skill.consecutiveCorrect >= MASTERY_CONFIG.consecutiveForMastery &&
|
||||
skill.attempts >= MASTERY_CONFIG.minimumAttempts &&
|
||||
skill.correct / skill.attempts >= MASTERY_CONFIG.accuracyThreshold
|
||||
) {
|
||||
return "mastered";
|
||||
}
|
||||
if (skill.attempts >= 5) {
|
||||
return 'practicing'
|
||||
return "practicing";
|
||||
}
|
||||
return 'learning'
|
||||
return "learning";
|
||||
}
|
||||
```
|
||||
|
||||
@@ -233,6 +239,7 @@ function updateMasteryLevel(skill: PlayerSkillMastery): MasteryLevel {
|
||||
### Worksheet Integration
|
||||
|
||||
When generating worksheets:
|
||||
|
||||
1. **No student selected**: Manual difficulty selection (current behavior)
|
||||
2. **Student selected**:
|
||||
- Pre-populate settings based on their curriculum position
|
||||
@@ -244,6 +251,7 @@ When generating worksheets:
|
||||
### Overview
|
||||
|
||||
A "session plan" is the system's recommendation for what a student should practice, generated based on:
|
||||
|
||||
- Available time (specified by teacher)
|
||||
- Student's current curriculum position
|
||||
- Skill mastery levels (what needs work vs. what's mastered)
|
||||
@@ -332,7 +340,7 @@ Both the **Plan Review** and **Active Session** screens include a "Config" butto
|
||||
│ PROBLEM CONSTRAINTS (Current Slot) │
|
||||
│ ├── slotIndex: 7 │
|
||||
│ ├── purpose: "focus" │
|
||||
│ ├── requiredSkills: { fiveComplements: { "3=5-2": true } } │
|
||||
│ ├── allowedSkills: { fiveComplements: { "3=5-2": true } } │
|
||||
│ ├── forbiddenSkills: { tenComplements: true } │
|
||||
│ ├── digitRange: { min: 1, max: 2 } │
|
||||
│ └── termCount: { min: 3, max: 5 } │
|
||||
@@ -353,12 +361,12 @@ Both the **Plan Review** and **Active Session** screens include a "Config" butto
|
||||
|
||||
Real-time metrics visible to the teacher during the active session:
|
||||
|
||||
| Indicator | 🟢 Good | 🟡 Warning | 🔴 Struggling |
|
||||
|-----------|---------|------------|---------------|
|
||||
| **Accuracy** | >80% | 60-80% | <60% |
|
||||
| **Pace** | On track or ahead | 10-30% behind | >30% behind |
|
||||
| **Streak** | 3+ consecutive correct | Mixed results | 3+ consecutive wrong |
|
||||
| **Engagement** | <60s per problem | 60-90s per problem | >90s or long pauses |
|
||||
| Indicator | 🟢 Good | 🟡 Warning | 🔴 Struggling |
|
||||
| -------------- | ---------------------- | ------------------ | -------------------- |
|
||||
| **Accuracy** | >80% | 60-80% | <60% |
|
||||
| **Pace** | On track or ahead | 10-30% behind | >30% behind |
|
||||
| **Streak** | 3+ consecutive correct | Mixed results | 3+ consecutive wrong |
|
||||
| **Engagement** | <60s per problem | 60-90s per problem | >90s or long pauses |
|
||||
|
||||
Overall session health is the worst of the four indicators.
|
||||
|
||||
@@ -366,14 +374,14 @@ Overall session health is the worst of the four indicators.
|
||||
|
||||
When the session isn't going well, the teacher can:
|
||||
|
||||
| Adjustment | Effect | When to Use |
|
||||
|------------|--------|-------------|
|
||||
| **Reduce Difficulty** | Switch remaining slots to easier problems | Accuracy < 60%, frustration visible |
|
||||
| **Enable Scaffolding** | Turn on visualization mode (show abacus) | Conceptual confusion |
|
||||
| **Narrow Focus** | Drop review/challenge, focus only on current skill | Overwhelmed by variety |
|
||||
| **Take a Break** | Pause timer, allow discussion | Long pauses, emotional state |
|
||||
| **Extend Session** | Add more problems | Going well, student wants more |
|
||||
| **End Gracefully** | Complete current problem, show summary | Time constraint, fatigue |
|
||||
| Adjustment | Effect | When to Use |
|
||||
| ---------------------- | -------------------------------------------------- | ----------------------------------- |
|
||||
| **Reduce Difficulty** | Switch remaining slots to easier problems | Accuracy < 60%, frustration visible |
|
||||
| **Enable Scaffolding** | Turn on visualization mode (show abacus) | Conceptual confusion |
|
||||
| **Narrow Focus** | Drop review/challenge, focus only on current skill | Overwhelmed by variety |
|
||||
| **Take a Break** | Pause timer, allow discussion | Long pauses, emotional state |
|
||||
| **Extend Session** | Add more problems | Going well, student wants more |
|
||||
| **End Gracefully** | Complete current problem, show summary | Time constraint, fatigue |
|
||||
|
||||
All adjustments are logged in `SessionPlan.adjustments[]` for later analysis.
|
||||
|
||||
@@ -381,89 +389,95 @@ All adjustments are logged in `SessionPlan.adjustments[]` for later analysis.
|
||||
|
||||
```typescript
|
||||
interface SessionPlan {
|
||||
id: string
|
||||
playerId: string
|
||||
id: string;
|
||||
playerId: string;
|
||||
|
||||
// Setup parameters
|
||||
targetDurationMinutes: number
|
||||
estimatedProblemCount: number
|
||||
avgTimePerProblemSeconds: number // Calculated from student history
|
||||
targetDurationMinutes: number;
|
||||
estimatedProblemCount: number;
|
||||
avgTimePerProblemSeconds: number; // Calculated from student history
|
||||
|
||||
// Problem slots (generated upfront, can be modified)
|
||||
slots: ProblemSlot[]
|
||||
slots: ProblemSlot[];
|
||||
|
||||
// Human-readable summary for plan review screen
|
||||
summary: SessionSummary
|
||||
summary: SessionSummary;
|
||||
|
||||
// State machine
|
||||
status: 'draft' | 'approved' | 'in_progress' | 'completed' | 'abandoned'
|
||||
status: "draft" | "approved" | "in_progress" | "completed" | "abandoned";
|
||||
|
||||
// Timestamps
|
||||
createdAt: Date
|
||||
approvedAt?: Date // When teacher/student clicked "Let's Go"
|
||||
startedAt?: Date // When first problem displayed
|
||||
completedAt?: Date
|
||||
createdAt: Date;
|
||||
approvedAt?: Date; // When teacher/student clicked "Let's Go"
|
||||
startedAt?: Date; // When first problem displayed
|
||||
completedAt?: Date;
|
||||
|
||||
// Live tracking
|
||||
currentSlotIndex: number
|
||||
sessionHealth: SessionHealth
|
||||
adjustments: SessionAdjustment[]
|
||||
currentSlotIndex: number;
|
||||
sessionHealth: SessionHealth;
|
||||
adjustments: SessionAdjustment[];
|
||||
|
||||
// Results (filled in as session progresses)
|
||||
results: SlotResult[]
|
||||
results: SlotResult[];
|
||||
}
|
||||
|
||||
interface ProblemSlot {
|
||||
index: number
|
||||
purpose: 'focus' | 'reinforce' | 'review' | 'challenge'
|
||||
index: number;
|
||||
purpose: "focus" | "reinforce" | "review" | "challenge";
|
||||
|
||||
// Constraints passed to problem generator
|
||||
constraints: {
|
||||
requiredSkills?: Partial<SkillSet>
|
||||
targetSkills?: Partial<SkillSet>
|
||||
forbiddenSkills?: Partial<SkillSet>
|
||||
digitRange?: { min: number; max: number }
|
||||
termCount?: { min: number; max: number }
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
}
|
||||
allowedSkills?: Partial<SkillSet>;
|
||||
targetSkills?: Partial<SkillSet>;
|
||||
forbiddenSkills?: Partial<SkillSet>;
|
||||
digitRange?: { min: number; max: number };
|
||||
termCount?: { min: number; max: number };
|
||||
operator?: "addition" | "subtraction" | "mixed";
|
||||
};
|
||||
|
||||
// Generated problem (filled when slot is reached)
|
||||
problem?: GeneratedProblem
|
||||
problem?: GeneratedProblem;
|
||||
}
|
||||
|
||||
interface SessionSummary {
|
||||
focusDescription: string // "Adding +3 using five-complement"
|
||||
focusCount: number
|
||||
reviewSkills: string[] // Human-readable skill names
|
||||
reviewCount: number
|
||||
challengeCount: number
|
||||
estimatedMinutes: number
|
||||
focusDescription: string; // "Adding +3 using five-complement"
|
||||
focusCount: number;
|
||||
reviewSkills: string[]; // Human-readable skill names
|
||||
reviewCount: number;
|
||||
challengeCount: number;
|
||||
estimatedMinutes: number;
|
||||
}
|
||||
|
||||
interface SessionHealth {
|
||||
overall: 'good' | 'warning' | 'struggling'
|
||||
accuracy: number // 0-1
|
||||
pacePercent: number // 100 = on track, <100 = behind
|
||||
currentStreak: number // Positive = correct streak, negative = wrong streak
|
||||
avgResponseTimeMs: number
|
||||
overall: "good" | "warning" | "struggling";
|
||||
accuracy: number; // 0-1
|
||||
pacePercent: number; // 100 = on track, <100 = behind
|
||||
currentStreak: number; // Positive = correct streak, negative = wrong streak
|
||||
avgResponseTimeMs: number;
|
||||
}
|
||||
|
||||
interface SessionAdjustment {
|
||||
timestamp: Date
|
||||
type: 'difficulty_reduced' | 'scaffolding_enabled' | 'focus_narrowed'
|
||||
| 'paused' | 'resumed' | 'extended' | 'ended_early'
|
||||
reason?: string // Optional teacher note
|
||||
previousHealth: SessionHealth
|
||||
timestamp: Date;
|
||||
type:
|
||||
| "difficulty_reduced"
|
||||
| "scaffolding_enabled"
|
||||
| "focus_narrowed"
|
||||
| "paused"
|
||||
| "resumed"
|
||||
| "extended"
|
||||
| "ended_early";
|
||||
reason?: string; // Optional teacher note
|
||||
previousHealth: SessionHealth;
|
||||
}
|
||||
|
||||
interface SlotResult {
|
||||
slotIndex: number
|
||||
problem: GeneratedProblem
|
||||
studentAnswer: number
|
||||
isCorrect: boolean
|
||||
responseTimeMs: number
|
||||
skillsExercised: string[] // Which skills this problem tested
|
||||
timestamp: Date
|
||||
slotIndex: number;
|
||||
problem: GeneratedProblem;
|
||||
studentAnswer: number;
|
||||
isCorrect: boolean;
|
||||
responseTimeMs: number;
|
||||
skillsExercised: string[]; // Which skills this problem tested
|
||||
timestamp: Date;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -472,100 +486,102 @@ interface SlotResult {
|
||||
```typescript
|
||||
interface PlanGenerationConfig {
|
||||
// Distribution weights (should sum to 1.0)
|
||||
focusWeight: number // Default: 0.60
|
||||
reinforceWeight: number // Default: 0.20
|
||||
reviewWeight: number // Default: 0.15
|
||||
challengeWeight: number // Default: 0.05
|
||||
focusWeight: number; // Default: 0.60
|
||||
reinforceWeight: number; // Default: 0.20
|
||||
reviewWeight: number; // Default: 0.15
|
||||
challengeWeight: number; // Default: 0.05
|
||||
|
||||
// Timing
|
||||
defaultSecondsPerProblem: number // Default: 45
|
||||
defaultSecondsPerProblem: number; // Default: 45
|
||||
|
||||
// Spaced repetition
|
||||
reviewIntervalDays: {
|
||||
mastered: number // Default: 7 (review mastered skills weekly)
|
||||
practicing: number // Default: 3 (review practicing skills every 3 days)
|
||||
}
|
||||
mastered: number; // Default: 7 (review mastered skills weekly)
|
||||
practicing: number; // Default: 3 (review practicing skills every 3 days)
|
||||
};
|
||||
}
|
||||
|
||||
function generateSessionPlan(
|
||||
playerId: string,
|
||||
durationMinutes: number,
|
||||
config: PlanGenerationConfig = DEFAULT_CONFIG
|
||||
config: PlanGenerationConfig = DEFAULT_CONFIG,
|
||||
): SessionPlan {
|
||||
|
||||
// 1. Load student state
|
||||
const curriculum = await getPlayerCurriculum(playerId)
|
||||
const skillMastery = await getAllSkillMastery(playerId)
|
||||
const recentSessions = await getRecentSessions(playerId, 10)
|
||||
const curriculum = await getPlayerCurriculum(playerId);
|
||||
const skillMastery = await getAllSkillMastery(playerId);
|
||||
const recentSessions = await getRecentSessions(playerId, 10);
|
||||
|
||||
// 2. Calculate personalized timing
|
||||
const avgTime = calculateAvgTimePerProblem(recentSessions)
|
||||
?? config.defaultSecondsPerProblem
|
||||
const problemCount = Math.floor((durationMinutes * 60) / avgTime)
|
||||
const avgTime =
|
||||
calculateAvgTimePerProblem(recentSessions) ??
|
||||
config.defaultSecondsPerProblem;
|
||||
const problemCount = Math.floor((durationMinutes * 60) / avgTime);
|
||||
|
||||
// 3. Categorize skills by need
|
||||
const currentPhaseSkills = getSkillsForPhase(curriculum.currentPhaseId)
|
||||
const struggling = skillMastery.filter(s =>
|
||||
currentPhaseSkills.includes(s.skillId) &&
|
||||
s.correct / s.attempts < 0.7
|
||||
)
|
||||
const needsReview = skillMastery.filter(s =>
|
||||
s.masteryLevel === 'mastered' &&
|
||||
daysSince(s.lastPracticedAt) > config.reviewIntervalDays.mastered
|
||||
)
|
||||
const currentPhaseSkills = getSkillsForPhase(curriculum.currentPhaseId);
|
||||
const struggling = skillMastery.filter(
|
||||
(s) =>
|
||||
currentPhaseSkills.includes(s.skillId) && s.correct / s.attempts < 0.7,
|
||||
);
|
||||
const needsReview = skillMastery.filter(
|
||||
(s) =>
|
||||
s.masteryLevel === "mastered" &&
|
||||
daysSince(s.lastPracticedAt) > config.reviewIntervalDays.mastered,
|
||||
);
|
||||
|
||||
// 4. Calculate slot distribution
|
||||
const focusCount = Math.round(problemCount * config.focusWeight)
|
||||
const reinforceCount = Math.round(problemCount * config.reinforceWeight)
|
||||
const reviewCount = Math.round(problemCount * config.reviewWeight)
|
||||
const challengeCount = problemCount - focusCount - reinforceCount - reviewCount
|
||||
const focusCount = Math.round(problemCount * config.focusWeight);
|
||||
const reinforceCount = Math.round(problemCount * config.reinforceWeight);
|
||||
const reviewCount = Math.round(problemCount * config.reviewWeight);
|
||||
const challengeCount =
|
||||
problemCount - focusCount - reinforceCount - reviewCount;
|
||||
|
||||
// 5. Build slots with constraints
|
||||
const slots: ProblemSlot[] = []
|
||||
const slots: ProblemSlot[] = [];
|
||||
|
||||
// Focus slots: current phase, primary skill
|
||||
for (let i = 0; i < focusCount; i++) {
|
||||
slots.push({
|
||||
index: slots.length,
|
||||
purpose: 'focus',
|
||||
constraints: buildConstraintsForPhase(curriculum.currentPhaseId)
|
||||
})
|
||||
purpose: "focus",
|
||||
constraints: buildConstraintsForPhase(curriculum.currentPhaseId),
|
||||
});
|
||||
}
|
||||
|
||||
// Reinforce slots: struggling skills get extra practice
|
||||
for (let i = 0; i < reinforceCount; i++) {
|
||||
const skill = struggling[i % struggling.length]
|
||||
const skill = struggling[i % struggling.length];
|
||||
slots.push({
|
||||
index: slots.length,
|
||||
purpose: 'reinforce',
|
||||
constraints: buildConstraintsForSkill(skill?.skillId)
|
||||
})
|
||||
purpose: "reinforce",
|
||||
constraints: buildConstraintsForSkill(skill?.skillId),
|
||||
});
|
||||
}
|
||||
|
||||
// Review slots: spaced repetition of mastered skills
|
||||
for (let i = 0; i < reviewCount; i++) {
|
||||
const skill = needsReview[i % needsReview.length]
|
||||
const skill = needsReview[i % needsReview.length];
|
||||
slots.push({
|
||||
index: slots.length,
|
||||
purpose: 'review',
|
||||
constraints: buildConstraintsForSkill(skill?.skillId)
|
||||
})
|
||||
purpose: "review",
|
||||
constraints: buildConstraintsForSkill(skill?.skillId),
|
||||
});
|
||||
}
|
||||
|
||||
// Challenge slots: slightly harder or mixed
|
||||
for (let i = 0; i < challengeCount; i++) {
|
||||
slots.push({
|
||||
index: slots.length,
|
||||
purpose: 'challenge',
|
||||
constraints: buildChallengeConstraints(curriculum)
|
||||
})
|
||||
purpose: "challenge",
|
||||
constraints: buildChallengeConstraints(curriculum),
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Shuffle to interleave purposes (but keep some focus problems together)
|
||||
const shuffledSlots = intelligentShuffle(slots)
|
||||
const shuffledSlots = intelligentShuffle(slots);
|
||||
|
||||
// 7. Build summary
|
||||
const summary = buildHumanReadableSummary(shuffledSlots, curriculum)
|
||||
const summary = buildHumanReadableSummary(shuffledSlots, curriculum);
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
@@ -575,13 +591,19 @@ function generateSessionPlan(
|
||||
avgTimePerProblemSeconds: avgTime,
|
||||
slots: shuffledSlots,
|
||||
summary,
|
||||
status: 'draft',
|
||||
status: "draft",
|
||||
createdAt: new Date(),
|
||||
currentSlotIndex: 0,
|
||||
sessionHealth: { overall: 'good', accuracy: 1, pacePercent: 100, currentStreak: 0, avgResponseTimeMs: 0 },
|
||||
sessionHealth: {
|
||||
overall: "good",
|
||||
accuracy: 1,
|
||||
pacePercent: 100,
|
||||
currentStreak: 0,
|
||||
avgResponseTimeMs: 0,
|
||||
},
|
||||
adjustments: [],
|
||||
results: []
|
||||
}
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
@@ -676,8 +698,6 @@ The practice experience is the actual problem-solving interface where the studen
|
||||
│ │ ● ● ● ● ○ ○ ○ ○ ○ │ │
|
||||
│ └───────────────────────┘ │
|
||||
│ │
|
||||
│ 3D Model: public/3d-models/simplified.abacus.stl │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -688,6 +708,7 @@ The curriculum uses two distinct problem formats:
|
||||
#### 1. Vertical (Columnar) Format - Primary
|
||||
|
||||
This is the main format from the workbooks. Numbers are stacked vertically:
|
||||
|
||||
- **Plus sign omitted** - Addition is implicit
|
||||
- **Minus sign shown** - Only subtraction is marked
|
||||
- **Answer box at bottom** - Student fills in the result
|
||||
@@ -755,23 +776,24 @@ After visualization practice, students progress to linear problems - sequences p
|
||||
|
||||
Based on the workbook format, a typical daily practice session has three parts:
|
||||
|
||||
| Part | Format | Abacus | Purpose |
|
||||
|------|--------|--------|---------|
|
||||
| **Part 1: Skill Building** | Vertical | Physical abacus | Build muscle memory, learn techniques |
|
||||
| **Part 2: Visualization** | Vertical | Hidden/mental | Internalize bead movements mentally |
|
||||
| **Part 3: Mental Math** | Linear | None | Pure mental calculation, no visual aid |
|
||||
| Part | Format | Abacus | Purpose |
|
||||
| -------------------------- | -------- | --------------- | -------------------------------------- |
|
||||
| **Part 1: Skill Building** | Vertical | Physical abacus | Build muscle memory, learn techniques |
|
||||
| **Part 2: Visualization** | Vertical | Hidden/mental | Internalize bead movements mentally |
|
||||
| **Part 3: Mental Math** | Linear | None | Pure mental calculation, no visual aid |
|
||||
|
||||
### Input Methods
|
||||
|
||||
| Device | Primary Input | Implementation |
|
||||
|--------|---------------|----------------|
|
||||
| **Desktop/Laptop** | Native keyboard | `<input type="number">` with auto-focus |
|
||||
| **Tablet with keyboard** | Native keyboard | Same as desktop |
|
||||
| **Phone/Touch tablet** | Virtual keypad | `react-simple-keyboard` numeric layout |
|
||||
| Device | Primary Input | Implementation |
|
||||
| ------------------------ | --------------- | --------------------------------------- |
|
||||
| **Desktop/Laptop** | Native keyboard | `<input type="number">` with auto-focus |
|
||||
| **Tablet with keyboard** | Native keyboard | Same as desktop |
|
||||
| **Phone/Touch tablet** | Virtual keypad | `react-simple-keyboard` numeric layout |
|
||||
|
||||
#### Phone Keypad Implementation
|
||||
|
||||
Reference existing implementations:
|
||||
|
||||
- **Know Your World**: `src/arcade-games/know-your-world/components/SimpleLetterKeyboard.tsx`
|
||||
- Uses `react-simple-keyboard` v3.8.139
|
||||
- Configured for letter input in learning mode
|
||||
@@ -782,19 +804,14 @@ Reference existing implementations:
|
||||
```typescript
|
||||
// Simplified numeric keypad for practice
|
||||
const numericLayout = {
|
||||
default: [
|
||||
'7 8 9',
|
||||
'4 5 6',
|
||||
'1 2 3',
|
||||
'{bksp} 0 {enter}'
|
||||
]
|
||||
}
|
||||
default: ["7 8 9", "4 5 6", "1 2 3", "{bksp} 0 {enter}"],
|
||||
};
|
||||
|
||||
// Use device detection from memory quiz
|
||||
const useDeviceType = () => {
|
||||
// Returns 'desktop' | 'tablet' | 'phone'
|
||||
// Based on screen size and touch capability
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Abacus Access
|
||||
@@ -849,6 +866,7 @@ When `visualizationMode: true` in the student's curriculum settings:
|
||||
```
|
||||
|
||||
**Visualization mode behaviors**:
|
||||
|
||||
- Hide "Show Abacus" button entirely
|
||||
- Add gentle reminder: "Picture the beads in your mind"
|
||||
- If student struggles (2+ wrong in a row):
|
||||
@@ -862,7 +880,8 @@ When `visualizationMode: true` in the student's curriculum settings:
|
||||
**CRITICAL**: Never present problems requiring skills the student hasn't learned yet.
|
||||
|
||||
The problem generator (`src/utils/problemGenerator.ts`) already supports:
|
||||
- `requiredSkills` - Skills the problem MUST use
|
||||
|
||||
- `allowedSkills` - Skills the problem MUST use
|
||||
- `targetSkills` - Skills we're trying to practice
|
||||
- `forbiddenSkills` - Skills the problem must NOT use
|
||||
|
||||
@@ -870,18 +889,19 @@ The problem generator (`src/utils/problemGenerator.ts`) already supports:
|
||||
// For a Level 1 student who has only learned +1, +2, +3 direct addition:
|
||||
const constraints = {
|
||||
forbiddenSkills: {
|
||||
fiveComplements: true, // No five-complement techniques
|
||||
tenComplements: true, // No ten-complement techniques
|
||||
tenComplementsSub: true, // No subtraction borrowing
|
||||
fiveComplementsSub: true, // No subtraction with fives
|
||||
fiveComplements: true, // No five-complement techniques
|
||||
tenComplements: true, // No ten-complement techniques
|
||||
tenComplementsSub: true, // No subtraction borrowing
|
||||
fiveComplementsSub: true, // No subtraction with fives
|
||||
},
|
||||
requiredSkills: {
|
||||
basic: { directAddition: true }
|
||||
}
|
||||
}
|
||||
allowedSkills: {
|
||||
basic: { directAddition: true },
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Audit checklist for problem generation**:
|
||||
|
||||
1. ✅ `analyzeRequiredSkills()` accurately categorizes all techniques needed
|
||||
2. ✅ `problemMatchesSkills()` correctly validates against constraints
|
||||
3. ⏳ Create curriculum phase → constraints mapping
|
||||
@@ -889,34 +909,33 @@ const constraints = {
|
||||
|
||||
### Existing Components to Leverage
|
||||
|
||||
| Component | Location | Purpose |
|
||||
|-----------|----------|---------|
|
||||
| `PracticeProblemPlayer` | `src/components/tutorial/PracticeProblemPlayer.tsx` | Existing practice UI (abacus-based input) |
|
||||
| `SimpleLetterKeyboard` | `src/arcade-games/know-your-world/components/SimpleLetterKeyboard.tsx` | `react-simple-keyboard` integration |
|
||||
| `InputPhase` | `src/arcade-games/memory-quiz/components/InputPhase.tsx` | Custom numeric keypad + device detection |
|
||||
| `problemGenerator` | `src/utils/problemGenerator.ts` | Skill-constrained problem generation |
|
||||
| `AbacusReact` | `@soroban/abacus-react` | On-screen abacus (last resort) |
|
||||
| 3D Abacus Model | `public/3d-models/simplified.abacus.stl` | Physical abacus recommendation |
|
||||
| Component | Location | Purpose |
|
||||
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------- |
|
||||
| `PracticeProblemPlayer` | `src/components/tutorial/PracticeProblemPlayer.tsx` | Existing practice UI (abacus-based input) |
|
||||
| `SimpleLetterKeyboard` | `src/arcade-games/know-your-world/components/SimpleLetterKeyboard.tsx` | `react-simple-keyboard` integration |
|
||||
| `InputPhase` | `src/arcade-games/memory-quiz/components/InputPhase.tsx` | Custom numeric keypad + device detection |
|
||||
| `problemGenerator` | `src/utils/problemGenerator.ts` | Skill-constrained problem generation |
|
||||
| `AbacusReact` | `@soroban/abacus-react` | On-screen abacus (last resort) |
|
||||
|
||||
### Data Model Extensions
|
||||
|
||||
```typescript
|
||||
interface PracticeAnswer {
|
||||
slotIndex: number
|
||||
studentAnswer: number
|
||||
isCorrect: boolean
|
||||
responseTimeMs: number
|
||||
inputMethod: 'keyboard' | 'virtual_keypad' | 'touch'
|
||||
usedOnScreenAbacus: boolean // Track abacus usage
|
||||
visualizationMode: boolean // Was this in visualization mode?
|
||||
slotIndex: number;
|
||||
studentAnswer: number;
|
||||
isCorrect: boolean;
|
||||
responseTimeMs: number;
|
||||
inputMethod: "keyboard" | "virtual_keypad" | "touch";
|
||||
usedOnScreenAbacus: boolean; // Track abacus usage
|
||||
visualizationMode: boolean; // Was this in visualization mode?
|
||||
}
|
||||
|
||||
// For identifying students who may need a physical abacus
|
||||
interface StudentAbacusUsage {
|
||||
onScreenAbacusUsed: number // Count of problems using on-screen
|
||||
totalProblems: number
|
||||
usageRate: number // Percentage
|
||||
suggestPhysicalAbacus: boolean // true if usage rate > 30%
|
||||
onScreenAbacusUsed: number; // Count of problems using on-screen
|
||||
totalProblems: number;
|
||||
usageRate: number; // Percentage
|
||||
suggestPhysicalAbacus: boolean; // true if usage rate > 30%
|
||||
}
|
||||
```
|
||||
|
||||
@@ -991,6 +1010,7 @@ interface StudentAbacusUsage {
|
||||
**Goal**: Create database tables and basic UI for tracking student progress through the curriculum.
|
||||
|
||||
**Tasks**:
|
||||
|
||||
1. ✅ Create `player_curriculum` table schema - `src/db/schema/player-curriculum.ts`
|
||||
2. ✅ Create `player_skill_mastery` table schema - `src/db/schema/player-skill-mastery.ts`
|
||||
3. ✅ Create `practice_sessions` table schema - `src/db/schema/practice-sessions.ts`
|
||||
@@ -1003,6 +1023,7 @@ interface StudentAbacusUsage {
|
||||
10. ✅ Create `/practice` page - `src/app/practice/page.tsx`
|
||||
|
||||
**Files Created**:
|
||||
|
||||
- ✅ `src/db/schema/player-curriculum.ts` - Curriculum position tracking
|
||||
- ✅ `src/db/schema/player-skill-mastery.ts` - Per-skill mastery tracking with `MASTERY_CONFIG` and `calculateMasteryLevel()`
|
||||
- ✅ `src/db/schema/practice-sessions.ts` - Practice session history
|
||||
@@ -1024,6 +1045,7 @@ interface StudentAbacusUsage {
|
||||
**Goal**: Enable the problem generator to handle subtraction and properly categorize "with/without friends of 5".
|
||||
|
||||
**Tasks**:
|
||||
|
||||
1. ✅ Add `analyzeColumnSubtraction()` function - `src/utils/problemGenerator.ts:148`
|
||||
2. ✅ Add subtraction skills to `SkillSet` type - `src/types/tutorial.ts:36`
|
||||
- `fiveComplementsSub`: `-4=-5+1`, `-3=-5+2`, `-2=-5+3`, `-1=-5+4`
|
||||
@@ -1039,23 +1061,26 @@ interface StudentAbacusUsage {
|
||||
**Goal**: Define the Level 1/2/3 structure as data that drives practice.
|
||||
|
||||
**Tasks**:
|
||||
|
||||
1. Create curriculum data structure:
|
||||
|
||||
```typescript
|
||||
interface CurriculumLevel {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
phases: CurriculumPhase[]
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
phases: CurriculumPhase[];
|
||||
}
|
||||
|
||||
interface CurriculumPhase {
|
||||
targetNumber: number // +1, +2, ... +9 or -9, -8, ... -1
|
||||
operation: 'addition' | 'subtraction'
|
||||
useFiveComplement: boolean
|
||||
usesTenComplement: boolean
|
||||
practiceStep: PracticeStep // Existing type
|
||||
targetNumber: number; // +1, +2, ... +9 or -9, -8, ... -1
|
||||
operation: "addition" | "subtraction";
|
||||
useFiveComplement: boolean;
|
||||
usesTenComplement: boolean;
|
||||
practiceStep: PracticeStep; // Existing type
|
||||
}
|
||||
```
|
||||
|
||||
2. Define all phases for Level 1, 2, 3
|
||||
3. Create helper to convert curriculum phase to PracticeStep constraints
|
||||
|
||||
@@ -1064,6 +1089,7 @@ interface StudentAbacusUsage {
|
||||
**Goal**: A `/practice` page that guides students through the curriculum with intelligent session planning.
|
||||
|
||||
**Tasks**:
|
||||
|
||||
1. ✅ Create `/app/practice/page.tsx` - Basic structure done
|
||||
2. ✅ Track current position in curriculum - Database schema done
|
||||
3. ⏳ Create session plan generator (`src/lib/curriculum/session-planner.ts`)
|
||||
@@ -1080,18 +1106,21 @@ interface StudentAbacusUsage {
|
||||
**Sub-phases**:
|
||||
|
||||
#### Phase 3a: Session Plan Generation
|
||||
|
||||
- Create `SessionPlan` type definitions
|
||||
- Implement `generateSessionPlan()` algorithm
|
||||
- Create `session_plans` table schema
|
||||
- API: POST `/api/curriculum/{playerId}/sessions/plan`
|
||||
|
||||
#### Phase 3b: Plan Review UI
|
||||
|
||||
- Plan summary display
|
||||
- Configuration inspector (debug panel)
|
||||
- "Adjust Plan" controls
|
||||
- "Let's Go" approval flow
|
||||
|
||||
#### Phase 3c: Active Session UI (Practice Experience)
|
||||
|
||||
- One-problem-at-a-time display with progress bar
|
||||
- Timer and pace tracking
|
||||
- Device-appropriate input:
|
||||
@@ -1111,6 +1140,7 @@ interface StudentAbacusUsage {
|
||||
- Configuration inspector (current slot details)
|
||||
|
||||
#### Phase 3d: Session Completion
|
||||
|
||||
- Summary display with results
|
||||
- Mastery level changes
|
||||
- Skill update and persistence
|
||||
@@ -1121,6 +1151,7 @@ interface StudentAbacusUsage {
|
||||
**Goal**: Generate printable worksheets targeting specific techniques.
|
||||
|
||||
**Tasks**:
|
||||
|
||||
1. Add "technique mode" to worksheet config
|
||||
2. Allow selecting specific curriculum phase for worksheet
|
||||
3. Generate problems using same constraints as online practice
|
||||
@@ -1130,6 +1161,7 @@ interface StudentAbacusUsage {
|
||||
### Skill Analysis Logic
|
||||
|
||||
**Current addition analysis** (from `analyzeColumnAddition`):
|
||||
|
||||
- Checks if adding `termDigit` to `currentDigit` requires:
|
||||
- Direct addition (result ≤ 4)
|
||||
- Heaven bead (involves 5)
|
||||
@@ -1137,6 +1169,7 @@ interface StudentAbacusUsage {
|
||||
- Ten complement (needs -n+10)
|
||||
|
||||
**Subtraction analysis** (to implement):
|
||||
|
||||
- Check if subtracting `termDigit` from `currentDigit` requires:
|
||||
- Direct subtraction (have enough earth beads)
|
||||
- Heaven bead removal (have 5-bead to remove)
|
||||
@@ -1150,18 +1183,28 @@ Use `forbiddenSkills` to exclude five-complement techniques:
|
||||
```typescript
|
||||
// Level 1, +3, WITHOUT friends of 5
|
||||
const practiceStep: PracticeStep = {
|
||||
requiredSkills: { basic: { directAddition: true, heavenBead: true } },
|
||||
targetSkills: { /* target +3 specifically */ },
|
||||
forbiddenSkills: {
|
||||
fiveComplements: { '3=5-2': true, '2=5-3': true, '1=5-4': true, '4=5-1': true }
|
||||
allowedSkills: { basic: { directAddition: true, heavenBead: true } },
|
||||
targetSkills: {
|
||||
/* target +3 specifically */
|
||||
},
|
||||
}
|
||||
forbiddenSkills: {
|
||||
fiveComplements: {
|
||||
"3=5-2": true,
|
||||
"2=5-3": true,
|
||||
"1=5-4": true,
|
||||
"4=5-1": true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Level 1, +3, WITH friends of 5
|
||||
const practiceStep: PracticeStep = {
|
||||
requiredSkills: { basic: { directAddition: true, heavenBead: true }, fiveComplements: { '2=5-3': true } },
|
||||
targetSkills: { fiveComplements: { '2=5-3': true } }, // Specifically target +3 via +5-2
|
||||
}
|
||||
allowedSkills: {
|
||||
basic: { directAddition: true, heavenBead: true },
|
||||
fiveComplements: { "2=5-3": true },
|
||||
},
|
||||
targetSkills: { fiveComplements: { "2=5-3": true } }, // Specifically target +3 via +5-2
|
||||
};
|
||||
```
|
||||
|
||||
## Assessment Data to Track
|
||||
@@ -1181,12 +1224,12 @@ const practiceStep: PracticeStep = {
|
||||
|
||||
## Questions Resolved
|
||||
|
||||
| Question | Answer |
|
||||
|----------|--------|
|
||||
| Problem format? | Multi-term sequences (3-7 terms), like the books |
|
||||
| Single-digit first? | No, double-digit from the start |
|
||||
| Question | Answer |
|
||||
| ------------------- | --------------------------------------------------- |
|
||||
| Problem format? | Multi-term sequences (3-7 terms), like the books |
|
||||
| Single-digit first? | No, double-digit from the start |
|
||||
| Visualization mode? | No abacus visible - that's the point of mental math |
|
||||
| Adaptive mastery? | Yes, continue until demonstrated proficiency |
|
||||
| Adaptive mastery? | Yes, continue until demonstrated proficiency |
|
||||
|
||||
## Sources
|
||||
|
||||
|
||||
3
apps/web/drizzle/0028_medical_wolfsbane.sql
Normal file
3
apps/web/drizzle/0028_medical_wolfsbane.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Custom SQL migration file, put your code below! --
|
||||
-- Add mastered_skill_ids column to session_plans for skill mismatch detection
|
||||
ALTER TABLE `session_plans` ADD `mastered_skill_ids` text DEFAULT '[]' NOT NULL;
|
||||
6
apps/web/drizzle/0029_first_black_tarantula.sql
Normal file
6
apps/web/drizzle/0029_first_black_tarantula.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Custom SQL migration file, put your code below! --
|
||||
-- Add response time tracking columns to player_skill_mastery table
|
||||
|
||||
ALTER TABLE `player_skill_mastery` ADD `total_response_time_ms` integer DEFAULT 0 NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `player_skill_mastery` ADD `response_time_count` integer DEFAULT 0 NOT NULL;
|
||||
4
apps/web/drizzle/0030_tan_jean_grey.sql
Normal file
4
apps/web/drizzle/0030_tan_jean_grey.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Add is_practicing boolean column to player_skill_mastery
|
||||
-- This replaces the 3-state mastery_level with a simple boolean
|
||||
-- Fluency state (effortless/fluent/rusty/practicing) is now computed from practice history
|
||||
ALTER TABLE `player_skill_mastery` ADD `is_practicing` integer DEFAULT 0 NOT NULL;
|
||||
4
apps/web/drizzle/0031_boring_namora.sql
Normal file
4
apps/web/drizzle/0031_boring_namora.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Populate is_practicing from existing mastery_level data
|
||||
-- mastered or practicing -> is_practicing = 1 (true)
|
||||
-- learning -> is_practicing = 0 (false)
|
||||
UPDATE `player_skill_mastery` SET `is_practicing` = 1 WHERE `mastery_level` IN ('mastered', 'practicing');
|
||||
5
apps/web/drizzle/0032_drop_mastery_level_column.sql
Normal file
5
apps/web/drizzle/0032_drop_mastery_level_column.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Custom SQL migration file, put your code below! --
|
||||
-- Drop the deprecated mastery_level column from player_skill_mastery table
|
||||
-- This column has been replaced by isPracticing + computed fluency state
|
||||
|
||||
ALTER TABLE `player_skill_mastery` DROP COLUMN `mastery_level`;
|
||||
5
apps/web/drizzle/0033_swift_eddie_brock.sql
Normal file
5
apps/web/drizzle/0033_swift_eddie_brock.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Custom SQL migration file, put your code below! --
|
||||
-- Add problem generation mode column to player_curriculum table
|
||||
-- 'adaptive' = BKT-based continuous scaling (default)
|
||||
-- 'classic' = Fluency-based discrete states
|
||||
ALTER TABLE `player_curriculum` ADD `problem_generation_mode` text DEFAULT 'adaptive' NOT NULL;
|
||||
26
apps/web/drizzle/0034_skill_tutorial_progress.sql
Normal file
26
apps/web/drizzle/0034_skill_tutorial_progress.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Custom SQL migration for skill_tutorial_progress table
|
||||
-- Tracks tutorial completion status for each skill per player
|
||||
|
||||
CREATE TABLE `skill_tutorial_progress` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`player_id` text NOT NULL,
|
||||
`skill_id` text NOT NULL,
|
||||
`tutorial_completed` integer DEFAULT 0 NOT NULL,
|
||||
`completed_at` integer,
|
||||
`teacher_override` integer DEFAULT 0 NOT NULL,
|
||||
`override_at` integer,
|
||||
`override_reason` text,
|
||||
`skip_count` integer DEFAULT 0 NOT NULL,
|
||||
`last_skipped_at` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`player_id`) REFERENCES `players`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Index for fast lookups by player
|
||||
CREATE INDEX `skill_tutorial_progress_player_id_idx` ON `skill_tutorial_progress` (`player_id`);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Unique constraint: one record per player per skill
|
||||
CREATE UNIQUE INDEX `skill_tutorial_progress_player_skill_unique` ON `skill_tutorial_progress` (`player_id`, `skill_id`);
|
||||
9
apps/web/drizzle/0035_cold_slapstick.sql
Normal file
9
apps/web/drizzle/0035_cold_slapstick.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- App-wide settings table (single row)
|
||||
CREATE TABLE `app_settings` (
|
||||
`id` text PRIMARY KEY DEFAULT 'default' NOT NULL,
|
||||
`bkt_confidence_threshold` real DEFAULT 0.3 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Insert the default row
|
||||
INSERT INTO `app_settings` (`id`, `bkt_confidence_threshold`) VALUES ('default', 0.3);
|
||||
3
apps/web/drizzle/0036_lonely_roland_deschain.sql
Normal file
3
apps/web/drizzle/0036_lonely_roland_deschain.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Custom SQL migration file, put your code below! --
|
||||
-- Add notes column to players table for teacher notes
|
||||
ALTER TABLE `players` ADD `notes` text;
|
||||
5
apps/web/drizzle/0037_drop_practice_sessions.sql
Normal file
5
apps/web/drizzle/0037_drop_practice_sessions.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Drop the practice_sessions table
|
||||
-- This table was replaced by session_plans which stores richer session data
|
||||
-- The table has 0 rows in production - all session data is in session_plans
|
||||
|
||||
DROP TABLE IF EXISTS `practice_sessions`;
|
||||
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;
|
||||
1038
apps/web/drizzle/meta/0028_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0029_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0030_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0030_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0031_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0031_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0032_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0032_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0033_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0033_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0034_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0034_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0035_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0035_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0036_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0036_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0037_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0037_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
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
@@ -197,6 +197,188 @@
|
||||
"when": 1765055035935,
|
||||
"tag": "0027_help_system_schema",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "6",
|
||||
"when": 1765331044112,
|
||||
"tag": "0028_medical_wolfsbane",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "6",
|
||||
"when": 1765496987070,
|
||||
"tag": "0029_first_black_tarantula",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "6",
|
||||
"when": 1765586703691,
|
||||
"tag": "0030_tan_jean_grey",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "6",
|
||||
"when": 1765586735162,
|
||||
"tag": "0031_boring_namora",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "6",
|
||||
"when": 1765594487576,
|
||||
"tag": "0032_drop_mastery_level_column",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 33,
|
||||
"version": "6",
|
||||
"when": 1765747888277,
|
||||
"tag": "0033_swift_eddie_brock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "6",
|
||||
"when": 1765939218325,
|
||||
"tag": "0034_skill_tutorial_progress",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "6",
|
||||
"when": 1765988633495,
|
||||
"tag": "0035_cold_slapstick",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "6",
|
||||
"when": 1766059382290,
|
||||
"tag": "0036_lonely_roland_deschain",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 37,
|
||||
"version": "6",
|
||||
"when": 1766068695014,
|
||||
"tag": "0037_drop_practice_sessions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 38,
|
||||
"version": "6",
|
||||
"when": 1766246063026,
|
||||
"tag": "0038_drop_skill_stat_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 39,
|
||||
"version": "6",
|
||||
"when": 1766275200000,
|
||||
"tag": "0039_add_player_archived",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "6",
|
||||
"when": 1766320890578,
|
||||
"tag": "0040_rename_last_help_level_to_last_had_help",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "6",
|
||||
"when": 1766404380000,
|
||||
"tag": "0041_classroom-system",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 42,
|
||||
"version": "6",
|
||||
"when": 1766406120000,
|
||||
"tag": "0042_classroom-system-indexes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "6",
|
||||
"when": 1766706763639,
|
||||
"tag": "0043_add_session_pause_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 44,
|
||||
"version": "6",
|
||||
"when": 1766773151809,
|
||||
"tag": "0044_add_is_paused_column",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 45,
|
||||
"version": "6",
|
||||
"when": 1766885087540,
|
||||
"tag": "0045_add_player_stats_table",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 46,
|
||||
"version": "6",
|
||||
"when": 1766980800000,
|
||||
"tag": "0046_session_observation_shares",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 47,
|
||||
"version": "6",
|
||||
"when": 1767037546552,
|
||||
"tag": "0047_add_entry_prompts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "6",
|
||||
"when": 1767044481301,
|
||||
"tag": "0048_ambitious_firedrake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 49,
|
||||
"version": "6",
|
||||
"when": 1767060697736,
|
||||
"tag": "0049_flowery_jean_grey",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 50,
|
||||
"version": "6",
|
||||
"when": 1767144779337,
|
||||
"tag": "0050_abandoned_salo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 51,
|
||||
"version": "6",
|
||||
"when": 1767205428875,
|
||||
"tag": "0051_luxuriant_selene",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 52,
|
||||
"version": "6",
|
||||
"when": 1767206527582,
|
||||
"tag": "0052_remarkable_karnak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 53,
|
||||
"version": "6",
|
||||
"when": 1767208127241,
|
||||
"tag": "0053_premium_expediter",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && concurrently \"node server.js\" \"npx @pandacss/dev --watch\"",
|
||||
"build": "node scripts/generate-build-info.js && npx tsx scripts/generateAllDayIcons.tsx && npx @pandacss/dev && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && next build",
|
||||
"build": "node scripts/generate-build-info.js && npx tsx scripts/generateAllDayIcons.tsx && npx @pandacss/dev && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && npm run build:seed-script && next build",
|
||||
"start": "NODE_ENV=production node server.js",
|
||||
"lint": "npx @biomejs/biome lint . && npx eslint .",
|
||||
"lint:fix": "npx @biomejs/biome lint . --write && npx eslint . --fix",
|
||||
@@ -22,7 +22,9 @@
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:drop": "drizzle-kit drop"
|
||||
"db:drop": "drizzle-kit drop",
|
||||
"seed:test-students": "npx tsx scripts/seedTestStudents.ts",
|
||||
"build:seed-script": "npx esbuild scripts/seedTestStudents.ts --bundle --platform=node --packages=external --outfile=dist/seedTestStudents.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -59,19 +61,28 @@
|
||||
"@svg-maps/world": "^2.0.0",
|
||||
"@tanstack/react-form": "^0.19.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-virtual": "^3.13.13",
|
||||
"@tensorflow/tfjs": "^4.22.0",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"d3-force": "^3.0.0",
|
||||
"drizzle-orm": "^0.44.6",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.5",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"emojibase-data": "^16.0.3",
|
||||
"framer-motion": "^12.23.26",
|
||||
"gray-matter": "^4.0.3",
|
||||
"jose": "^6.1.0",
|
||||
"js-aruco2": "^2.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jscanify": "^1.4.0",
|
||||
"jspdf": "^3.0.4",
|
||||
"lib0": "^0.2.114",
|
||||
"lucide-react": "^0.294.0",
|
||||
"make-plural": "^7.4.0",
|
||||
@@ -79,7 +90,6 @@
|
||||
"next": "^14.2.32",
|
||||
"next-auth": "5.0.0-beta.29",
|
||||
"next-intl": "^4.4.0",
|
||||
"openscad-wasm-prebuilt": "^1.2.0",
|
||||
"python-bridge": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode.react": "^4.2.0",
|
||||
@@ -89,6 +99,7 @@
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-simple-keyboard": "^3.8.139",
|
||||
"react-textfit": "^1.1.1",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-slug": "^6.0.0",
|
||||
@@ -113,6 +124,7 @@
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.0.0",
|
||||
@@ -123,11 +135,13 @@
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"concurrently": "^8.2.2",
|
||||
"drizzle-kit": "^0.31.5",
|
||||
"esbuild": "^0.27.2",
|
||||
"eslint": "^8.0.0",
|
||||
"eslint-config-next": "^14.0.0",
|
||||
"eslint-plugin-storybook": "^9.1.7",
|
||||
"happy-dom": "^18.0.1",
|
||||
"jsdom": "^27.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"storybook": "^9.1.7",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsx": "^4.20.5",
|
||||
|
||||
@@ -261,6 +261,16 @@ export default defineConfig({
|
||||
'0%, 100%': { filter: 'hue-rotate(0deg)' },
|
||||
'50%': { filter: 'hue-rotate(20deg)' },
|
||||
},
|
||||
// Accordion slide down - expand content smoothly
|
||||
accordionSlideDown: {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
// Accordion slide up - collapse content smoothly
|
||||
accordionSlideUp: {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// Inline version of abacus.scad that doesn't require BOSL2
|
||||
// This version uses a hardcoded bounding box size instead of the bounding_box() function
|
||||
|
||||
// ---- USER CUSTOMIZABLE PARAMETERS ----
|
||||
// These can be overridden via command line: -D 'columns=7' etc.
|
||||
columns = 13; // Total number of columns (1-13, mirrored book design)
|
||||
scale_factor = 1.5; // Overall size scale (preserves aspect ratio)
|
||||
// -----------------------------------------
|
||||
|
||||
stl_path = "/3d-models/simplified.abacus.stl";
|
||||
|
||||
// Known bounding box dimensions of the simplified.abacus.stl file
|
||||
// These were measured from the original file
|
||||
bbox_size = [186, 60, 120]; // [width, depth, height] in STL units
|
||||
|
||||
// Calculate parameters based on column count
|
||||
// The full STL has 13 columns. We want columns/2 per side (mirrored).
|
||||
total_columns_in_stl = 13;
|
||||
columns_per_side = columns / 2;
|
||||
width_scale = columns_per_side / total_columns_in_stl;
|
||||
|
||||
// Column spacing: distance between mirrored halves
|
||||
units_per_column = bbox_size[0] / total_columns_in_stl; // ~14.3 units per column
|
||||
column_spacing = columns_per_side * units_per_column;
|
||||
|
||||
// --- actual model ---
|
||||
module imported() {
|
||||
import(stl_path, convexity = 10);
|
||||
}
|
||||
|
||||
// Create a bounding box manually instead of using BOSL2's bounding_box()
|
||||
module bounding_box_manual() {
|
||||
translate([-bbox_size[0]/2, -bbox_size[1]/2, -bbox_size[2]/2])
|
||||
cube(bbox_size);
|
||||
}
|
||||
|
||||
module half_abacus() {
|
||||
intersection() {
|
||||
scale([width_scale, 1, 1]) bounding_box_manual();
|
||||
imported();
|
||||
}
|
||||
}
|
||||
|
||||
scale([scale_factor, scale_factor, scale_factor]) {
|
||||
translate([column_spacing, 0, 0]) mirror([1,0,0]) half_abacus();
|
||||
half_abacus();
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
include <BOSL2/std.scad>; // BOSL2 v2.0 or newer
|
||||
|
||||
// ---- USER CUSTOMIZABLE PARAMETERS ----
|
||||
// These can be overridden via command line: -D 'columns=7' etc.
|
||||
columns = 13; // Total number of columns (1-13, mirrored book design)
|
||||
scale_factor = 1.5; // Overall size scale (preserves aspect ratio)
|
||||
// -----------------------------------------
|
||||
|
||||
stl_path = "./simplified.abacus.stl";
|
||||
|
||||
// Calculate parameters based on column count
|
||||
// The full STL has 13 columns. We want columns/2 per side (mirrored).
|
||||
// The original bounding box intersection: scale([35/186, 1, 1])
|
||||
// 35/186 ≈ 0.188 = ~2.44 columns, so 186 units ≈ 13 columns, ~14.3 units per column
|
||||
total_columns_in_stl = 13;
|
||||
columns_per_side = columns / 2;
|
||||
width_scale = columns_per_side / total_columns_in_stl;
|
||||
|
||||
// Column spacing: distance between mirrored halves
|
||||
// Original spacing of 69 for ~2.4 columns/side
|
||||
// Calculate proportional spacing based on columns
|
||||
units_per_column = 186 / total_columns_in_stl; // ~14.3 units per column
|
||||
column_spacing = columns_per_side * units_per_column;
|
||||
|
||||
// --- actual model ---
|
||||
module imported()
|
||||
import(stl_path, convexity = 10);
|
||||
|
||||
module half_abacus() {
|
||||
intersection() {
|
||||
scale([width_scale, 1, 1]) bounding_box() imported();
|
||||
imported();
|
||||
}
|
||||
}
|
||||
|
||||
scale([scale_factor, scale_factor, scale_factor]) {
|
||||
translate([column_spacing, 0, 0]) mirror([1,0,0]) half_abacus();
|
||||
half_abacus();
|
||||
}
|
||||
Binary file not shown.
161
apps/web/public/data/ab-mastery-trajectories.json
Normal file
161
apps/web/public/data/ab-mastery-trajectories.json
Normal file
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"generatedAt": "2025-12-16T19:26:34.484Z",
|
||||
"version": "1.0",
|
||||
"config": {
|
||||
"seed": 98765,
|
||||
"sessionCount": 12,
|
||||
"sessionDurationMinutes": 15
|
||||
},
|
||||
"summary": {
|
||||
"totalSkills": 6,
|
||||
"adaptiveWins50": 4,
|
||||
"classicWins50": 0,
|
||||
"ties50": 2,
|
||||
"adaptiveWins80": 6,
|
||||
"classicWins80": 0,
|
||||
"ties80": 0
|
||||
},
|
||||
"sessions": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
"skills": [
|
||||
{
|
||||
"id": "fiveComplements.3=5-2",
|
||||
"label": "5-comp: 3=5-2",
|
||||
"category": "fiveComplement",
|
||||
"color": "#eab308",
|
||||
"adaptive": {
|
||||
"data": [25, 75, 85, 89, 93, 94, 95, 96, 97, 97, 98, 98],
|
||||
"sessionsTo50": 2,
|
||||
"sessionsTo80": 3
|
||||
},
|
||||
"classic": {
|
||||
"data": [25, 54, 67, 82, 87, 90, 93, 94, 96, 97, 97, 98],
|
||||
"sessionsTo50": 2,
|
||||
"sessionsTo80": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fiveComplementsSub.-3=-5+2",
|
||||
"label": "5-comp sub: -3=-5+2",
|
||||
"category": "fiveComplement",
|
||||
"color": "#facc15",
|
||||
"adaptive": {
|
||||
"data": [2, 27, 57, 80, 89, 90, 92, 93, 94, 95, 96, 96],
|
||||
"sessionsTo50": 3,
|
||||
"sessionsTo80": 4
|
||||
},
|
||||
"classic": {
|
||||
"data": [2, 27, 32, 54, 63, 70, 79, 84, 87, 88, 90, 92],
|
||||
"sessionsTo50": 4,
|
||||
"sessionsTo80": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tenComplements.9=10-1",
|
||||
"label": "10-comp: 9=10-1",
|
||||
"category": "tenComplement",
|
||||
"color": "#dc2626",
|
||||
"adaptive": {
|
||||
"data": [20, 63, 85, 89, 93, 94, 95, 96, 97, 97, 98, 98],
|
||||
"sessionsTo50": 2,
|
||||
"sessionsTo80": 3
|
||||
},
|
||||
"classic": {
|
||||
"data": [20, 50, 69, 78, 86, 90, 93, 95, 96, 96, 97, 98],
|
||||
"sessionsTo50": 2,
|
||||
"sessionsTo80": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tenComplements.5=10-5",
|
||||
"label": "10-comp: 5=10-5",
|
||||
"category": "tenComplement",
|
||||
"color": "#ea580c",
|
||||
"adaptive": {
|
||||
"data": [5, 44, 71, 82, 88, 90, 91, 92, 93, 94, 95, 95],
|
||||
"sessionsTo50": 3,
|
||||
"sessionsTo80": 4
|
||||
},
|
||||
"classic": {
|
||||
"data": [5, 10, 16, 31, 44, 47, 64, 72, 77, 83, 87, 87],
|
||||
"sessionsTo50": 7,
|
||||
"sessionsTo80": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tenComplementsSub.-9=+1-10",
|
||||
"label": "10-comp sub: -9=+1-10",
|
||||
"category": "tenComplement",
|
||||
"color": "#ef4444",
|
||||
"adaptive": {
|
||||
"data": [3, 40, 70, 72, 79, 80, 83, 87, 89, 91, 92, 92],
|
||||
"sessionsTo50": 3,
|
||||
"sessionsTo80": 6
|
||||
},
|
||||
"classic": {
|
||||
"data": [3, 11, 22, 33, 53, 56, 63, 68, 72, 76, 77, 80],
|
||||
"sessionsTo50": 5,
|
||||
"sessionsTo80": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tenComplementsSub.-5=+5-10",
|
||||
"label": "10-comp sub: -5=+5-10",
|
||||
"category": "tenComplement",
|
||||
"color": "#f97316",
|
||||
"adaptive": {
|
||||
"data": [1, 6, 44, 67, 78, 81, 83, 85, 87, 88, 89, 90],
|
||||
"sessionsTo50": 4,
|
||||
"sessionsTo80": 6
|
||||
},
|
||||
"classic": {
|
||||
"data": [1, 6, 15, 25, 29, 38, 44, 50, 61, 67, 70, 74],
|
||||
"sessionsTo50": 8,
|
||||
"sessionsTo80": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"comparisonTable": [
|
||||
{
|
||||
"skill": "5-comp: 3=5-2",
|
||||
"category": "fiveComplement",
|
||||
"adaptiveTo80": 3,
|
||||
"classicTo80": 4,
|
||||
"advantage": "Adaptive +1 sessions"
|
||||
},
|
||||
{
|
||||
"skill": "5-comp sub: -3=-5+2",
|
||||
"category": "fiveComplement",
|
||||
"adaptiveTo80": 4,
|
||||
"classicTo80": 8,
|
||||
"advantage": "Adaptive +4 sessions"
|
||||
},
|
||||
{
|
||||
"skill": "10-comp: 9=10-1",
|
||||
"category": "tenComplement",
|
||||
"adaptiveTo80": 3,
|
||||
"classicTo80": 5,
|
||||
"advantage": "Adaptive +2 sessions"
|
||||
},
|
||||
{
|
||||
"skill": "10-comp: 5=10-5",
|
||||
"category": "tenComplement",
|
||||
"adaptiveTo80": 4,
|
||||
"classicTo80": 10,
|
||||
"advantage": "Adaptive +6 sessions"
|
||||
},
|
||||
{
|
||||
"skill": "10-comp sub: -9=+1-10",
|
||||
"category": "tenComplement",
|
||||
"adaptiveTo80": 6,
|
||||
"classicTo80": 12,
|
||||
"advantage": "Adaptive +6 sessions"
|
||||
},
|
||||
{
|
||||
"skill": "10-comp sub: -5=+5-10",
|
||||
"category": "tenComplement",
|
||||
"adaptiveTo80": 6,
|
||||
"classicTo80": null,
|
||||
"advantage": "Adaptive (Classic never reached 80%)"
|
||||
}
|
||||
]
|
||||
}
|
||||
209
apps/web/public/data/skill-difficulty-report.json
Normal file
209
apps/web/public/data/skill-difficulty-report.json
Normal file
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"generatedAt": "2025-12-16T15:51:01.133Z",
|
||||
"version": "1.0",
|
||||
"summary": {
|
||||
"basicAvgExposures": 16.666666666666668,
|
||||
"fiveCompAvgExposures": 24,
|
||||
"tenCompAvgExposures": 36,
|
||||
"gapAt20Exposures": "36.2 percentage points",
|
||||
"exposureRatioForEqualMastery": "1.92"
|
||||
},
|
||||
"masteryCurves": {
|
||||
"exposurePoints": [5, 10, 15, 20, 25, 30, 40, 50],
|
||||
"skills": [
|
||||
{
|
||||
"id": "basic.directAddition",
|
||||
"label": "Basic (0.8x)",
|
||||
"category": "basic",
|
||||
"color": "#22c55e",
|
||||
"data": [28.000000000000004, 61, 78, 86, 91, 93, 96, 98]
|
||||
},
|
||||
{
|
||||
"id": "fiveComplements.4=5-1",
|
||||
"label": "Five-Complement (1.2x)",
|
||||
"category": "fiveComplement",
|
||||
"color": "#eab308",
|
||||
"data": [15, 41, 61, 74, 81, 86, 92, 95]
|
||||
},
|
||||
{
|
||||
"id": "tenComplements.9=10-1",
|
||||
"label": "Ten-Complement Easy (1.6x)",
|
||||
"category": "tenComplement",
|
||||
"color": "#f97316",
|
||||
"data": [9, 28.000000000000004, 47, 61, 71, 78, 86, 91]
|
||||
},
|
||||
{
|
||||
"id": "tenComplements.1=10-9",
|
||||
"label": "Ten-Complement Hard (2.0x)",
|
||||
"category": "tenComplement",
|
||||
"color": "#ef4444",
|
||||
"data": [6, 20, 36, 50, 61, 69, 80, 86]
|
||||
}
|
||||
]
|
||||
},
|
||||
"abComparison": {
|
||||
"exposurePoints": [5, 10, 15, 20, 25, 30, 40, 50],
|
||||
"withDifficulty": {
|
||||
"basic.directAddition": {
|
||||
"avgAt20": 0.86
|
||||
},
|
||||
"fiveComplements.4=5-1": {
|
||||
"avgAt20": 0.74
|
||||
},
|
||||
"tenComplements.1=10-9": {
|
||||
"avgAt20": 0.5
|
||||
},
|
||||
"tenComplements.9=10-1": {
|
||||
"avgAt20": 0.61
|
||||
}
|
||||
},
|
||||
"withoutDifficulty": {
|
||||
"basic.directAddition": {
|
||||
"avgAt20": 0.8
|
||||
},
|
||||
"fiveComplements.4=5-1": {
|
||||
"avgAt20": 0.8
|
||||
},
|
||||
"tenComplements.1=10-9": {
|
||||
"avgAt20": 0.8
|
||||
},
|
||||
"tenComplements.9=10-1": {
|
||||
"avgAt20": 0.8
|
||||
}
|
||||
}
|
||||
},
|
||||
"exposuresToMastery": {
|
||||
"target": "80%",
|
||||
"categories": [
|
||||
{
|
||||
"name": "Basic Skills",
|
||||
"avgExposures": 16.666666666666668,
|
||||
"color": "#22c55e",
|
||||
"skills": [
|
||||
{
|
||||
"id": "basic.directAddition",
|
||||
"exposures": 16
|
||||
},
|
||||
{
|
||||
"id": "basic.directSubtraction",
|
||||
"exposures": 16
|
||||
},
|
||||
{
|
||||
"id": "basic.heavenBead",
|
||||
"exposures": 18
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Five-Complements",
|
||||
"avgExposures": 24,
|
||||
"color": "#eab308",
|
||||
"skills": [
|
||||
{
|
||||
"id": "fiveComplements.1=5-4",
|
||||
"exposures": 24
|
||||
},
|
||||
{
|
||||
"id": "fiveComplements.3=5-2",
|
||||
"exposures": 24
|
||||
},
|
||||
{
|
||||
"id": "fiveComplements.4=5-1",
|
||||
"exposures": 24
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Ten-Complements",
|
||||
"avgExposures": 36,
|
||||
"color": "#ef4444",
|
||||
"skills": [
|
||||
{
|
||||
"id": "tenComplements.1=10-9",
|
||||
"exposures": 40
|
||||
},
|
||||
{
|
||||
"id": "tenComplements.6=10-4",
|
||||
"exposures": 36
|
||||
},
|
||||
{
|
||||
"id": "tenComplements.9=10-1",
|
||||
"exposures": 32
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"fiftyPercentThresholds": {
|
||||
"exposuresFor50Percent": {
|
||||
"basic.directAddition": 8,
|
||||
"fiveComplements.4=5-1": 12,
|
||||
"tenComplements.1=10-9": 20,
|
||||
"tenComplements.9=10-1": 16
|
||||
},
|
||||
"ratiosRelativeToBasic": {
|
||||
"basic.directAddition": "1.00",
|
||||
"fiveComplements.4=5-1": "1.50",
|
||||
"tenComplements.1=10-9": "2.50",
|
||||
"tenComplements.9=10-1": "2.00"
|
||||
}
|
||||
},
|
||||
"masteryTable": [
|
||||
{
|
||||
"Basic (0.8x)": "0%",
|
||||
"Five-Comp (1.2x)": "0%",
|
||||
"Ten-Comp Easy (1.6x)": "0%",
|
||||
"Ten-Comp Hard (2.0x)": "0%",
|
||||
"exposures": 0
|
||||
},
|
||||
{
|
||||
"Basic (0.8x)": "28%",
|
||||
"Five-Comp (1.2x)": "15%",
|
||||
"Ten-Comp Easy (1.6x)": "9%",
|
||||
"Ten-Comp Hard (2.0x)": "6%",
|
||||
"exposures": 5
|
||||
},
|
||||
{
|
||||
"Basic (0.8x)": "61%",
|
||||
"Five-Comp (1.2x)": "41%",
|
||||
"Ten-Comp Easy (1.6x)": "28%",
|
||||
"Ten-Comp Hard (2.0x)": "20%",
|
||||
"exposures": 10
|
||||
},
|
||||
{
|
||||
"Basic (0.8x)": "78%",
|
||||
"Five-Comp (1.2x)": "61%",
|
||||
"Ten-Comp Easy (1.6x)": "47%",
|
||||
"Ten-Comp Hard (2.0x)": "36%",
|
||||
"exposures": 15
|
||||
},
|
||||
{
|
||||
"Basic (0.8x)": "86%",
|
||||
"Five-Comp (1.2x)": "74%",
|
||||
"Ten-Comp Easy (1.6x)": "61%",
|
||||
"Ten-Comp Hard (2.0x)": "50%",
|
||||
"exposures": 20
|
||||
},
|
||||
{
|
||||
"Basic (0.8x)": "93%",
|
||||
"Five-Comp (1.2x)": "86%",
|
||||
"Ten-Comp Easy (1.6x)": "78%",
|
||||
"Ten-Comp Hard (2.0x)": "69%",
|
||||
"exposures": 30
|
||||
},
|
||||
{
|
||||
"Basic (0.8x)": "96%",
|
||||
"Five-Comp (1.2x)": "92%",
|
||||
"Ten-Comp Easy (1.6x)": "86%",
|
||||
"Ten-Comp Hard (2.0x)": "80%",
|
||||
"exposures": 40
|
||||
},
|
||||
{
|
||||
"Basic (0.8x)": "98%",
|
||||
"Five-Comp (1.2x)": "95%",
|
||||
"Ten-Comp Easy (1.6x)": "91%",
|
||||
"Ten-Comp Hard (2.0x)": "86%",
|
||||
"exposures": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
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
|
||||
}
|
||||
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
254
apps/web/scripts/generateMasteryTrajectoryData.ts
Normal file
254
apps/web/scripts/generateMasteryTrajectoryData.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Generate JSON data from A/B mastery trajectory test snapshots.
|
||||
*
|
||||
* This script reads the Vitest snapshot file and extracts the multi-skill
|
||||
* A/B trajectory data into a JSON format for the blog post charts.
|
||||
*
|
||||
* Usage: npx tsx scripts/generateMasteryTrajectoryData.ts
|
||||
* Output: public/data/ab-mastery-trajectories.json
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const SNAPSHOT_PATH = path.join(
|
||||
process.cwd(),
|
||||
'src/test/journey-simulator/__snapshots__/skill-difficulty.test.ts.snap'
|
||||
)
|
||||
|
||||
const OUTPUT_PATH = path.join(process.cwd(), 'public/data/ab-mastery-trajectories.json')
|
||||
|
||||
interface TrajectoryPoint {
|
||||
session: number
|
||||
mastery: number
|
||||
}
|
||||
|
||||
interface SkillTrajectory {
|
||||
adaptive: TrajectoryPoint[]
|
||||
classic: TrajectoryPoint[]
|
||||
sessionsTo50Adaptive: number | null
|
||||
sessionsTo50Classic: number | null
|
||||
sessionsTo80Adaptive: number | null
|
||||
sessionsTo80Classic: number | null
|
||||
}
|
||||
|
||||
interface ABMasterySnapshot {
|
||||
config: {
|
||||
seed: number
|
||||
sessionCount: number
|
||||
sessionDurationMinutes: number
|
||||
}
|
||||
summary: {
|
||||
skills: string[]
|
||||
adaptiveWins50: number
|
||||
classicWins50: number
|
||||
ties50: number
|
||||
adaptiveWins80: number
|
||||
classicWins80: number
|
||||
ties80: number
|
||||
}
|
||||
trajectories: Record<string, SkillTrajectory>
|
||||
}
|
||||
|
||||
function parseSnapshotFile(content: string): ABMasterySnapshot | null {
|
||||
// Extract the ab-mastery-trajectories snapshot using regex
|
||||
const regex = /exports\[`[^\]]*ab-mastery-trajectories[^\]]*`\]\s*=\s*`([\s\S]*?)`\s*;/m
|
||||
const match = content.match(regex)
|
||||
if (!match) {
|
||||
console.warn('Warning: Could not find ab-mastery-trajectories snapshot')
|
||||
return null
|
||||
}
|
||||
try {
|
||||
// The snapshot content is a JavaScript object literal, parse it
|
||||
// biome-ignore lint/security/noGlobalEval: parsing vitest snapshot format requires eval
|
||||
return eval(`(${match[1]})`) as ABMasterySnapshot
|
||||
} catch (e) {
|
||||
console.error('Error parsing snapshot:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Categorize skill IDs for display
|
||||
function getSkillCategory(skillId: string): 'fiveComplement' | 'tenComplement' | 'basic' {
|
||||
if (skillId.startsWith('fiveComplements') || skillId.startsWith('fiveComplementsSub')) {
|
||||
return 'fiveComplement'
|
||||
}
|
||||
if (skillId.startsWith('tenComplements') || skillId.startsWith('tenComplementsSub')) {
|
||||
return 'tenComplement'
|
||||
}
|
||||
return 'basic'
|
||||
}
|
||||
|
||||
// Generate a human-readable label for skill IDs
|
||||
function getSkillLabel(skillId: string): string {
|
||||
// Extract the formula part after the dot
|
||||
const parts = skillId.split('.')
|
||||
if (parts.length < 2) return skillId
|
||||
|
||||
const formula = parts[1]
|
||||
|
||||
// Categorize by type
|
||||
if (skillId.startsWith('fiveComplements.')) {
|
||||
return `5-comp: ${formula}`
|
||||
}
|
||||
if (skillId.startsWith('fiveComplementsSub.')) {
|
||||
return `5-comp sub: ${formula}`
|
||||
}
|
||||
if (skillId.startsWith('tenComplements.')) {
|
||||
return `10-comp: ${formula}`
|
||||
}
|
||||
if (skillId.startsWith('tenComplementsSub.')) {
|
||||
return `10-comp sub: ${formula}`
|
||||
}
|
||||
return skillId
|
||||
}
|
||||
|
||||
// Get color for skill based on category
|
||||
function getSkillColor(skillId: string, index: number): string {
|
||||
const category = getSkillCategory(skillId)
|
||||
|
||||
// Color palettes by category
|
||||
const colors = {
|
||||
fiveComplement: ['#eab308', '#facc15'], // yellows
|
||||
tenComplement: ['#ef4444', '#f97316', '#dc2626', '#ea580c'], // reds/oranges
|
||||
basic: ['#22c55e', '#16a34a'], // greens
|
||||
}
|
||||
|
||||
const palette = colors[category]
|
||||
return palette[index % palette.length]
|
||||
}
|
||||
|
||||
function generateReport(data: ABMasterySnapshot) {
|
||||
const skills = data.summary.skills
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
|
||||
// Config used to generate this data
|
||||
config: data.config,
|
||||
|
||||
// Summary statistics
|
||||
summary: {
|
||||
totalSkills: skills.length,
|
||||
adaptiveWins50: data.summary.adaptiveWins50,
|
||||
classicWins50: data.summary.classicWins50,
|
||||
ties50: data.summary.ties50,
|
||||
adaptiveWins80: data.summary.adaptiveWins80,
|
||||
classicWins80: data.summary.classicWins80,
|
||||
ties80: data.summary.ties80,
|
||||
},
|
||||
|
||||
// Session labels (x-axis)
|
||||
sessions: Array.from({ length: data.config.sessionCount }, (_, i) => i + 1),
|
||||
|
||||
// Skills with their trajectory data
|
||||
skills: skills.map((skillId, i) => {
|
||||
const trajectory = data.trajectories[skillId]
|
||||
return {
|
||||
id: skillId,
|
||||
label: getSkillLabel(skillId),
|
||||
category: getSkillCategory(skillId),
|
||||
color: getSkillColor(skillId, i),
|
||||
adaptive: {
|
||||
data: trajectory.adaptive.map((p) => Math.round(p.mastery * 100)),
|
||||
sessionsTo50: trajectory.sessionsTo50Adaptive,
|
||||
sessionsTo80: trajectory.sessionsTo80Adaptive,
|
||||
},
|
||||
classic: {
|
||||
data: trajectory.classic.map((p) => Math.round(p.mastery * 100)),
|
||||
sessionsTo50: trajectory.sessionsTo50Classic,
|
||||
sessionsTo80: trajectory.sessionsTo80Classic,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// Summary table for comparison
|
||||
comparisonTable: skills.map((skillId) => {
|
||||
const trajectory = data.trajectories[skillId]
|
||||
const sessionsTo80Adaptive = trajectory.sessionsTo80Adaptive
|
||||
const sessionsTo80Classic = trajectory.sessionsTo80Classic
|
||||
|
||||
// Calculate advantage
|
||||
let advantage: string | null = null
|
||||
if (sessionsTo80Adaptive !== null && sessionsTo80Classic !== null) {
|
||||
const diff = sessionsTo80Classic - sessionsTo80Adaptive
|
||||
if (diff > 0) {
|
||||
advantage = `Adaptive +${diff} sessions`
|
||||
} else if (diff < 0) {
|
||||
advantage = `Classic +${Math.abs(diff)} sessions`
|
||||
} else {
|
||||
advantage = 'Tie'
|
||||
}
|
||||
} else if (sessionsTo80Adaptive !== null && sessionsTo80Classic === null) {
|
||||
advantage = 'Adaptive (Classic never reached 80%)'
|
||||
} else if (sessionsTo80Adaptive === null && sessionsTo80Classic !== null) {
|
||||
advantage = 'Classic (Adaptive never reached 80%)'
|
||||
}
|
||||
|
||||
return {
|
||||
skill: getSkillLabel(skillId),
|
||||
category: getSkillCategory(skillId),
|
||||
adaptiveTo80: sessionsTo80Adaptive,
|
||||
classicTo80: sessionsTo80Classic,
|
||||
advantage,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Reading snapshot file...')
|
||||
|
||||
if (!fs.existsSync(SNAPSHOT_PATH)) {
|
||||
console.error(`Snapshot file not found: ${SNAPSHOT_PATH}`)
|
||||
console.log(
|
||||
'Run the tests first: npx vitest run src/test/journey-simulator/skill-difficulty.test.ts'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const snapshotContent = fs.readFileSync(SNAPSHOT_PATH, 'utf-8')
|
||||
console.log('Parsing snapshots...')
|
||||
|
||||
const data = parseSnapshotFile(snapshotContent)
|
||||
if (!data) {
|
||||
console.error('Failed to parse snapshot data')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('Generating report...')
|
||||
const report = generateReport(data)
|
||||
|
||||
// Ensure output directory exists
|
||||
const outputDir = path.dirname(OUTPUT_PATH)
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
|
||||
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(report, null, 2))
|
||||
console.log(`Report written to: ${OUTPUT_PATH}`)
|
||||
|
||||
// Print summary
|
||||
console.log('\n--- Summary ---')
|
||||
console.log(`Skills analyzed: ${report.summary.totalSkills}`)
|
||||
console.log(`Sessions: ${report.config.sessionCount}`)
|
||||
console.log(`\nAt 50% mastery threshold:`)
|
||||
console.log(` Adaptive wins: ${report.summary.adaptiveWins50}`)
|
||||
console.log(` Classic wins: ${report.summary.classicWins50}`)
|
||||
console.log(` Ties: ${report.summary.ties50}`)
|
||||
console.log(`\nAt 80% mastery threshold:`)
|
||||
console.log(` Adaptive wins: ${report.summary.adaptiveWins80}`)
|
||||
console.log(` Classic wins: ${report.summary.classicWins80}`)
|
||||
console.log(` Ties: ${report.summary.ties80}`)
|
||||
|
||||
console.log('\n--- Comparison Table ---')
|
||||
for (const row of report.comparisonTable) {
|
||||
const a80 = row.adaptiveTo80 !== null ? row.adaptiveTo80 : 'never'
|
||||
const c80 = row.classicTo80 !== null ? row.classicTo80 : 'never'
|
||||
console.log(`${row.skill}: Adaptive ${a80}, Classic ${c80} → ${row.advantage}`)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
280
apps/web/scripts/generateSkillDifficultyData.ts
Normal file
280
apps/web/scripts/generateSkillDifficultyData.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Generate JSON data from skill difficulty test snapshots.
|
||||
*
|
||||
* This script reads the Vitest snapshot file and extracts the data
|
||||
* into a JSON format that can be consumed by the blog post charts.
|
||||
*
|
||||
* Usage: npx tsx scripts/generateSkillDifficultyData.ts
|
||||
* Output: public/data/skill-difficulty-report.json
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const SNAPSHOT_PATH = path.join(
|
||||
process.cwd(),
|
||||
'src/test/journey-simulator/__snapshots__/skill-difficulty.test.ts.snap'
|
||||
)
|
||||
|
||||
const OUTPUT_PATH = path.join(process.cwd(), 'public/data/skill-difficulty-report.json')
|
||||
|
||||
interface SnapshotData {
|
||||
learningTrajectory: {
|
||||
exposuresToMastery: Record<string, number>
|
||||
categoryAverages: Record<string, number>
|
||||
}
|
||||
masteryCurves: {
|
||||
table: Array<{
|
||||
exposures: number
|
||||
[key: string]: string | number
|
||||
}>
|
||||
}
|
||||
fiftyPercentThresholds: {
|
||||
exposuresFor50Percent: Record<string, number>
|
||||
ratiosRelativeToBasic: Record<string, string>
|
||||
}
|
||||
abComparison: {
|
||||
withDifficulty: Record<string, number[]>
|
||||
withoutDifficulty: Record<string, number[]>
|
||||
summary: {
|
||||
withDifficulty: Record<string, { avgAt20: number }>
|
||||
withoutDifficulty: Record<string, { avgAt20: number }>
|
||||
}
|
||||
}
|
||||
learningExpectations: {
|
||||
at20Exposures: Record<string, string>
|
||||
gapBetweenEasiestAndHardest: string
|
||||
}
|
||||
exposureRatio: {
|
||||
basicExposures: number
|
||||
tenCompExposures: number
|
||||
ratio: string
|
||||
targetMastery: string
|
||||
}
|
||||
}
|
||||
|
||||
function parseSnapshotFile(content: string): SnapshotData {
|
||||
// Extract each snapshot export using regex
|
||||
const extractSnapshot = (name: string): unknown => {
|
||||
const regex = new RegExp(
|
||||
`exports\\[\`[^\\]]*${name}[^\\]]*\`\\]\\s*=\\s*\`([\\s\\S]*?)\`;`,
|
||||
'm'
|
||||
)
|
||||
const match = content.match(regex)
|
||||
if (!match) {
|
||||
console.warn(`Warning: Could not find snapshot: ${name}`)
|
||||
return null
|
||||
}
|
||||
try {
|
||||
// The snapshot content is a JavaScript object literal, parse it
|
||||
// eslint-disable-next-line no-eval
|
||||
return eval(`(${match[1]})`)
|
||||
} catch (e) {
|
||||
console.error(`Error parsing snapshot ${name}:`, e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const learningTrajectory = extractSnapshot('learning-trajectory-by-category') as {
|
||||
exposuresToMastery: Record<string, number>
|
||||
categoryAverages: Record<string, number>
|
||||
}
|
||||
|
||||
const masteryCurvesRaw = extractSnapshot('mastery-curves-table') as {
|
||||
table: Array<Record<string, string | number>>
|
||||
}
|
||||
|
||||
const fiftyPercent = extractSnapshot('fifty-percent-threshold-ratios') as {
|
||||
exposuresFor50Percent: Record<string, number>
|
||||
ratiosRelativeToBasic: Record<string, string>
|
||||
}
|
||||
|
||||
const abComparison = extractSnapshot('skill-difficulty-ab-comparison') as {
|
||||
withDifficulty: Record<string, number[]>
|
||||
withoutDifficulty: Record<string, number[]>
|
||||
summary: {
|
||||
withDifficulty: Record<string, { avgAt20: number }>
|
||||
withoutDifficulty: Record<string, { avgAt20: number }>
|
||||
}
|
||||
}
|
||||
|
||||
const learningExpectations = extractSnapshot('learning-expectations-validation') as {
|
||||
at20Exposures: Record<string, string>
|
||||
gapBetweenEasiestAndHardest: string
|
||||
}
|
||||
|
||||
const exposureRatio = extractSnapshot('exposure-ratio-for-equal-mastery') as {
|
||||
basicExposures: number
|
||||
tenCompExposures: number
|
||||
ratio: string
|
||||
targetMastery: string
|
||||
}
|
||||
|
||||
return {
|
||||
learningTrajectory,
|
||||
masteryCurves: masteryCurvesRaw,
|
||||
fiftyPercentThresholds: fiftyPercent,
|
||||
abComparison,
|
||||
learningExpectations,
|
||||
exposureRatio,
|
||||
}
|
||||
}
|
||||
|
||||
function generateReport(data: SnapshotData) {
|
||||
const exposurePoints = [5, 10, 15, 20, 25, 30, 40, 50]
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
|
||||
// Summary stats
|
||||
summary: {
|
||||
basicAvgExposures: data.learningTrajectory?.categoryAverages?.basic ?? 17,
|
||||
fiveCompAvgExposures: data.learningTrajectory?.categoryAverages?.fiveComplement ?? 24,
|
||||
tenCompAvgExposures: data.learningTrajectory?.categoryAverages?.tenComplement ?? 36,
|
||||
gapAt20Exposures:
|
||||
data.learningExpectations?.gapBetweenEasiestAndHardest ?? '36.2 percentage points',
|
||||
exposureRatioForEqualMastery: data.exposureRatio?.ratio ?? '1.92',
|
||||
},
|
||||
|
||||
// Data for mastery curves chart
|
||||
masteryCurves: {
|
||||
exposurePoints,
|
||||
skills: [
|
||||
{
|
||||
id: 'basic.directAddition',
|
||||
label: 'Basic (0.8x)',
|
||||
category: 'basic',
|
||||
color: '#22c55e', // green
|
||||
data: data.abComparison?.withDifficulty?.['basic.directAddition']?.map(
|
||||
(v) => v * 100
|
||||
) ?? [28, 61, 78, 86, 91, 93, 96, 98],
|
||||
},
|
||||
{
|
||||
id: 'fiveComplements.4=5-1',
|
||||
label: 'Five-Complement (1.2x)',
|
||||
category: 'fiveComplement',
|
||||
color: '#eab308', // yellow
|
||||
data: data.abComparison?.withDifficulty?.['fiveComplements.4=5-1']?.map(
|
||||
(v) => v * 100
|
||||
) ?? [15, 41, 61, 74, 81, 86, 92, 95],
|
||||
},
|
||||
{
|
||||
id: 'tenComplements.9=10-1',
|
||||
label: 'Ten-Complement Easy (1.6x)',
|
||||
category: 'tenComplement',
|
||||
color: '#f97316', // orange
|
||||
data: data.abComparison?.withDifficulty?.['tenComplements.9=10-1']?.map(
|
||||
(v) => v * 100
|
||||
) ?? [9, 28, 47, 61, 71, 78, 86, 91],
|
||||
},
|
||||
{
|
||||
id: 'tenComplements.1=10-9',
|
||||
label: 'Ten-Complement Hard (2.0x)',
|
||||
category: 'tenComplement',
|
||||
color: '#ef4444', // red
|
||||
data: data.abComparison?.withDifficulty?.['tenComplements.1=10-9']?.map(
|
||||
(v) => v * 100
|
||||
) ?? [6, 20, 36, 50, 61, 69, 80, 86],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Data for A/B comparison chart
|
||||
abComparison: {
|
||||
exposurePoints,
|
||||
withDifficulty: data.abComparison?.summary?.withDifficulty ?? {},
|
||||
withoutDifficulty: data.abComparison?.summary?.withoutDifficulty ?? {},
|
||||
},
|
||||
|
||||
// Data for exposures to mastery bar chart
|
||||
exposuresToMastery: {
|
||||
target: '80%',
|
||||
categories: [
|
||||
{
|
||||
name: 'Basic Skills',
|
||||
avgExposures: data.learningTrajectory?.categoryAverages?.basic ?? 17,
|
||||
color: '#22c55e',
|
||||
skills: Object.entries(data.learningTrajectory?.exposuresToMastery ?? {})
|
||||
.filter(([k]) => k.startsWith('basic.'))
|
||||
.map(([k, v]) => ({ id: k, exposures: v })),
|
||||
},
|
||||
{
|
||||
name: 'Five-Complements',
|
||||
avgExposures: data.learningTrajectory?.categoryAverages?.fiveComplement ?? 24,
|
||||
color: '#eab308',
|
||||
skills: Object.entries(data.learningTrajectory?.exposuresToMastery ?? {})
|
||||
.filter(([k]) => k.startsWith('fiveComplements.'))
|
||||
.map(([k, v]) => ({ id: k, exposures: v })),
|
||||
},
|
||||
{
|
||||
name: 'Ten-Complements',
|
||||
avgExposures: data.learningTrajectory?.categoryAverages?.tenComplement ?? 36,
|
||||
color: '#ef4444',
|
||||
skills: Object.entries(data.learningTrajectory?.exposuresToMastery ?? {})
|
||||
.filter(([k]) => k.startsWith('tenComplements.'))
|
||||
.map(([k, v]) => ({ id: k, exposures: v })),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Data for 50% threshold comparison
|
||||
fiftyPercentThresholds: data.fiftyPercentThresholds ?? {
|
||||
exposuresFor50Percent: {
|
||||
'basic.directAddition': 8,
|
||||
'fiveComplements.4=5-1': 12,
|
||||
'tenComplements.9=10-1': 16,
|
||||
'tenComplements.1=10-9': 20,
|
||||
},
|
||||
ratiosRelativeToBasic: {
|
||||
'basic.directAddition': '1.00',
|
||||
'fiveComplements.4=5-1': '1.50',
|
||||
'tenComplements.9=10-1': '2.00',
|
||||
'tenComplements.1=10-9': '2.50',
|
||||
},
|
||||
},
|
||||
|
||||
// Mastery table for tabular display
|
||||
masteryTable: data.masteryCurves?.table ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Reading snapshot file...')
|
||||
|
||||
if (!fs.existsSync(SNAPSHOT_PATH)) {
|
||||
console.error(`Snapshot file not found: ${SNAPSHOT_PATH}`)
|
||||
console.log(
|
||||
'Run the tests first: npx vitest run src/test/journey-simulator/skill-difficulty.test.ts'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const snapshotContent = fs.readFileSync(SNAPSHOT_PATH, 'utf-8')
|
||||
console.log('Parsing snapshots...')
|
||||
|
||||
const data = parseSnapshotFile(snapshotContent)
|
||||
console.log('Generating report...')
|
||||
|
||||
const report = generateReport(data)
|
||||
|
||||
// Ensure output directory exists
|
||||
const outputDir = path.dirname(OUTPUT_PATH)
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
|
||||
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(report, null, 2))
|
||||
console.log(`Report written to: ${OUTPUT_PATH}`)
|
||||
|
||||
// Print summary
|
||||
console.log('\n--- Summary ---')
|
||||
console.log(`Basic skills avg: ${report.summary.basicAvgExposures} exposures to 80%`)
|
||||
console.log(`Five-complements avg: ${report.summary.fiveCompAvgExposures} exposures to 80%`)
|
||||
console.log(`Ten-complements avg: ${report.summary.tenCompAvgExposures} exposures to 80%`)
|
||||
console.log(`Gap at 20 exposures: ${report.summary.gapAt20Exposures}`)
|
||||
console.log(`Exposure ratio (ten-comp/basic): ${report.summary.exposureRatioForEqualMastery}x`)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
345
apps/web/scripts/generateTrajectoryData.ts
Normal file
345
apps/web/scripts/generateTrajectoryData.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Generate A/B mastery trajectory data for all skills.
|
||||
* Runs simulations directly without vitest overhead.
|
||||
*
|
||||
* Usage: npx tsx scripts/generateTrajectoryData.ts
|
||||
* Output: public/data/ab-mastery-trajectories.json
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import * as schema from '../src/db/schema'
|
||||
import { SeededRandom } from '../src/test/journey-simulator/SeededRandom'
|
||||
import { SimulatedStudent } from '../src/test/journey-simulator/SimulatedStudent'
|
||||
import type { StudentProfile, JourneyConfig } from '../src/test/journey-simulator/types'
|
||||
|
||||
// All skills in the curriculum
|
||||
const ALL_SKILLS = [
|
||||
// Basic skills (6)
|
||||
'basic.directAddition',
|
||||
'basic.directSubtraction',
|
||||
'basic.heavenBead',
|
||||
'basic.heavenBeadSubtraction',
|
||||
'basic.simpleCombinations',
|
||||
'basic.simpleCombinationsSub',
|
||||
// Five complements addition (4)
|
||||
'fiveComplements.4=5-1',
|
||||
'fiveComplements.3=5-2',
|
||||
'fiveComplements.2=5-3',
|
||||
'fiveComplements.1=5-4',
|
||||
// Five complements subtraction (4)
|
||||
'fiveComplementsSub.-4=-5+1',
|
||||
'fiveComplementsSub.-3=-5+2',
|
||||
'fiveComplementsSub.-2=-5+3',
|
||||
'fiveComplementsSub.-1=-5+4',
|
||||
// Ten complements addition (9)
|
||||
'tenComplements.9=10-1',
|
||||
'tenComplements.8=10-2',
|
||||
'tenComplements.7=10-3',
|
||||
'tenComplements.6=10-4',
|
||||
'tenComplements.5=10-5',
|
||||
'tenComplements.4=10-6',
|
||||
'tenComplements.3=10-7',
|
||||
'tenComplements.2=10-8',
|
||||
'tenComplements.1=10-9',
|
||||
// Ten complements subtraction (9)
|
||||
'tenComplementsSub.-9=+1-10',
|
||||
'tenComplementsSub.-8=+2-10',
|
||||
'tenComplementsSub.-7=+3-10',
|
||||
'tenComplementsSub.-6=+4-10',
|
||||
'tenComplementsSub.-5=+5-10',
|
||||
'tenComplementsSub.-4=+6-10',
|
||||
'tenComplementsSub.-3=+7-10',
|
||||
'tenComplementsSub.-2=+8-10',
|
||||
'tenComplementsSub.-1=+9-10',
|
||||
// Advanced (2)
|
||||
'advanced.cascadingCarry',
|
||||
'advanced.cascadingBorrow',
|
||||
]
|
||||
|
||||
const OUTPUT_PATH = path.join(process.cwd(), 'public/data/ab-mastery-trajectories.json')
|
||||
|
||||
interface TrajectoryPoint {
|
||||
session: number
|
||||
mastery: number
|
||||
}
|
||||
|
||||
interface SkillTrajectory {
|
||||
adaptive: TrajectoryPoint[]
|
||||
classic: TrajectoryPoint[]
|
||||
sessionsTo50Adaptive: number | null
|
||||
sessionsTo50Classic: number | null
|
||||
sessionsTo80Adaptive: number | null
|
||||
sessionsTo80Classic: number | null
|
||||
}
|
||||
|
||||
// Simplified journey runner that just tracks mastery over sessions
|
||||
function runSimplifiedJourney(
|
||||
skillId: string,
|
||||
profile: StudentProfile,
|
||||
sessionCount: number,
|
||||
seed: number
|
||||
): TrajectoryPoint[] {
|
||||
const rng = new SeededRandom(seed)
|
||||
const student = new SimulatedStudent(profile, rng)
|
||||
|
||||
const trajectory: TrajectoryPoint[] = []
|
||||
|
||||
for (let session = 1; session <= sessionCount; session++) {
|
||||
// Simulate ~20 problems per session that exercise this skill
|
||||
for (let problem = 0; problem < 20; problem++) {
|
||||
// Simulate answering a problem with this skill
|
||||
const probability = student.getTrueProbability([skillId])
|
||||
const isCorrect = rng.chance(probability)
|
||||
|
||||
// Increment exposure (learning happens from practice)
|
||||
student.incrementExposure(skillId)
|
||||
}
|
||||
|
||||
// Record mastery at end of session
|
||||
const mastery = student.getTrueProbability([skillId])
|
||||
trajectory.push({ session, mastery })
|
||||
}
|
||||
|
||||
return trajectory
|
||||
}
|
||||
|
||||
function findSessionForMastery(trajectory: TrajectoryPoint[], threshold: number): number | null {
|
||||
for (const point of trajectory) {
|
||||
if (point.mastery >= threshold) {
|
||||
return point.session
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getSkillCategory(
|
||||
skillId: string
|
||||
): 'basic' | 'fiveComplement' | 'tenComplement' | 'advanced' {
|
||||
if (skillId.startsWith('basic.')) return 'basic'
|
||||
if (skillId.startsWith('fiveComplement')) return 'fiveComplement'
|
||||
if (skillId.startsWith('tenComplement')) return 'tenComplement'
|
||||
return 'advanced'
|
||||
}
|
||||
|
||||
function getSkillLabel(skillId: string): string {
|
||||
const parts = skillId.split('.')
|
||||
if (parts.length < 2) return skillId
|
||||
const formula = parts[1]
|
||||
|
||||
if (skillId.startsWith('basic.')) return `basic: ${formula}`
|
||||
if (skillId.startsWith('fiveComplements.')) return `5-comp: ${formula}`
|
||||
if (skillId.startsWith('fiveComplementsSub.')) return `5-comp sub: ${formula}`
|
||||
if (skillId.startsWith('tenComplements.')) return `10-comp: ${formula}`
|
||||
if (skillId.startsWith('tenComplementsSub.')) return `10-comp sub: ${formula}`
|
||||
if (skillId.startsWith('advanced.')) return `advanced: ${formula}`
|
||||
return skillId
|
||||
}
|
||||
|
||||
function getSkillColor(category: string, index: number): string {
|
||||
const palettes: Record<string, string[]> = {
|
||||
basic: ['#22c55e', '#16a34a', '#15803d', '#166534', '#14532d', '#052e16'],
|
||||
fiveComplement: ['#eab308', '#facc15', '#fde047', '#fef08a'],
|
||||
tenComplement: [
|
||||
'#ef4444',
|
||||
'#f97316',
|
||||
'#dc2626',
|
||||
'#ea580c',
|
||||
'#b91c1c',
|
||||
'#c2410c',
|
||||
'#991b1b',
|
||||
'#9a3412',
|
||||
'#7f1d1d',
|
||||
],
|
||||
advanced: ['#8b5cf6', '#a78bfa'],
|
||||
}
|
||||
const palette = palettes[category] || palettes.basic
|
||||
return palette[index % palette.length]
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Generating A/B mastery trajectory data for full curriculum...')
|
||||
console.log(`Skills to process: ${ALL_SKILLS.length}`)
|
||||
console.log('')
|
||||
|
||||
const sessionCount = 12
|
||||
const seed = 98765
|
||||
|
||||
// Profile for adaptive mode (BKT targeting)
|
||||
const adaptiveProfile: StudentProfile = {
|
||||
name: 'Adaptive Learner',
|
||||
description: 'Student using adaptive mode',
|
||||
halfMaxExposure: 10,
|
||||
hillCoefficient: 2.0,
|
||||
initialExposures: {}, // Start from zero
|
||||
helpUsageProbabilities: [0.7, 0.2, 0.08, 0.02],
|
||||
helpBonuses: [0, 0.05, 0.12, 0.25],
|
||||
baseResponseTimeMs: 5000,
|
||||
responseTimeVariance: 0.3,
|
||||
}
|
||||
|
||||
// Profile for classic mode (no BKT targeting, same learning rate)
|
||||
const classicProfile: StudentProfile = {
|
||||
...adaptiveProfile,
|
||||
name: 'Classic Learner',
|
||||
description: 'Student using classic mode',
|
||||
}
|
||||
|
||||
const trajectories: Record<string, SkillTrajectory> = {}
|
||||
const startTime = Date.now()
|
||||
|
||||
for (let i = 0; i < ALL_SKILLS.length; i++) {
|
||||
const skillId = ALL_SKILLS[i]
|
||||
const skillStart = Date.now()
|
||||
|
||||
process.stdout.write(`[${i + 1}/${ALL_SKILLS.length}] ${skillId}... `)
|
||||
|
||||
// Run adaptive simulation
|
||||
const adaptiveTrajectory = runSimplifiedJourney(skillId, adaptiveProfile, sessionCount, seed)
|
||||
|
||||
// Run classic simulation (different seed for variety)
|
||||
const classicTrajectory = runSimplifiedJourney(
|
||||
skillId,
|
||||
classicProfile,
|
||||
sessionCount,
|
||||
seed + 1000
|
||||
)
|
||||
|
||||
trajectories[skillId] = {
|
||||
adaptive: adaptiveTrajectory,
|
||||
classic: classicTrajectory,
|
||||
sessionsTo50Adaptive: findSessionForMastery(adaptiveTrajectory, 0.5),
|
||||
sessionsTo50Classic: findSessionForMastery(classicTrajectory, 0.5),
|
||||
sessionsTo80Adaptive: findSessionForMastery(adaptiveTrajectory, 0.8),
|
||||
sessionsTo80Classic: findSessionForMastery(classicTrajectory, 0.8),
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - skillStart
|
||||
console.log(`done (${elapsed}ms)`)
|
||||
}
|
||||
|
||||
// Compute summary
|
||||
let adaptiveWins50 = 0,
|
||||
classicWins50 = 0,
|
||||
ties50 = 0
|
||||
let adaptiveWins80 = 0,
|
||||
classicWins80 = 0,
|
||||
ties80 = 0
|
||||
|
||||
for (const skillId of ALL_SKILLS) {
|
||||
const t = trajectories[skillId]
|
||||
|
||||
// 50% comparison
|
||||
if (t.sessionsTo50Adaptive !== null && t.sessionsTo50Classic !== null) {
|
||||
if (t.sessionsTo50Adaptive < t.sessionsTo50Classic) adaptiveWins50++
|
||||
else if (t.sessionsTo50Adaptive > t.sessionsTo50Classic) classicWins50++
|
||||
else ties50++
|
||||
} else if (t.sessionsTo50Adaptive !== null) {
|
||||
adaptiveWins50++
|
||||
} else if (t.sessionsTo50Classic !== null) {
|
||||
classicWins50++
|
||||
} else {
|
||||
ties50++
|
||||
}
|
||||
|
||||
// 80% comparison
|
||||
if (t.sessionsTo80Adaptive !== null && t.sessionsTo80Classic !== null) {
|
||||
if (t.sessionsTo80Adaptive < t.sessionsTo80Classic) adaptiveWins80++
|
||||
else if (t.sessionsTo80Adaptive > t.sessionsTo80Classic) classicWins80++
|
||||
else ties80++
|
||||
} else if (t.sessionsTo80Adaptive !== null) {
|
||||
adaptiveWins80++
|
||||
} else if (t.sessionsTo80Classic !== null) {
|
||||
classicWins80++
|
||||
} else {
|
||||
ties80++
|
||||
}
|
||||
}
|
||||
|
||||
// Build output
|
||||
const categoryIndices: Record<string, number> = {}
|
||||
|
||||
const output = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
version: '2.0',
|
||||
config: { seed, sessionCount, sessionDurationMinutes: 15 },
|
||||
summary: {
|
||||
totalSkills: ALL_SKILLS.length,
|
||||
adaptiveWins50,
|
||||
classicWins50,
|
||||
ties50,
|
||||
adaptiveWins80,
|
||||
classicWins80,
|
||||
ties80,
|
||||
},
|
||||
sessions: Array.from({ length: sessionCount }, (_, i) => i + 1),
|
||||
skills: ALL_SKILLS.map((skillId) => {
|
||||
const category = getSkillCategory(skillId)
|
||||
categoryIndices[category] = categoryIndices[category] || 0
|
||||
const colorIndex = categoryIndices[category]++
|
||||
|
||||
const t = trajectories[skillId]
|
||||
return {
|
||||
id: skillId,
|
||||
label: getSkillLabel(skillId),
|
||||
category,
|
||||
color: getSkillColor(category, colorIndex),
|
||||
adaptive: {
|
||||
data: t.adaptive.map((p) => Math.round(p.mastery * 100)),
|
||||
sessionsTo50: t.sessionsTo50Adaptive,
|
||||
sessionsTo80: t.sessionsTo80Adaptive,
|
||||
},
|
||||
classic: {
|
||||
data: t.classic.map((p) => Math.round(p.mastery * 100)),
|
||||
sessionsTo50: t.sessionsTo50Classic,
|
||||
sessionsTo80: t.sessionsTo80Classic,
|
||||
},
|
||||
}
|
||||
}),
|
||||
comparisonTable: ALL_SKILLS.map((skillId) => {
|
||||
const t = trajectories[skillId]
|
||||
let advantage: string | null = null
|
||||
|
||||
if (t.sessionsTo80Adaptive !== null && t.sessionsTo80Classic !== null) {
|
||||
const diff = t.sessionsTo80Classic - t.sessionsTo80Adaptive
|
||||
if (diff > 0) advantage = `Adaptive +${diff} sessions`
|
||||
else if (diff < 0) advantage = `Classic +${Math.abs(diff)} sessions`
|
||||
else advantage = 'Tie'
|
||||
} else if (t.sessionsTo80Adaptive !== null) {
|
||||
advantage = 'Adaptive (Classic never reached 80%)'
|
||||
} else if (t.sessionsTo80Classic !== null) {
|
||||
advantage = 'Classic (Adaptive never reached 80%)'
|
||||
}
|
||||
|
||||
return {
|
||||
skill: getSkillLabel(skillId),
|
||||
category: getSkillCategory(skillId),
|
||||
adaptiveTo80: t.sessionsTo80Adaptive,
|
||||
classicTo80: t.sessionsTo80Classic,
|
||||
advantage,
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
// Write output
|
||||
const outputDir = path.dirname(OUTPUT_PATH)
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
|
||||
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2))
|
||||
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1)
|
||||
console.log('')
|
||||
console.log(`=== Complete in ${totalTime}s ===`)
|
||||
console.log(`Output: ${OUTPUT_PATH}`)
|
||||
console.log('')
|
||||
console.log('Summary:')
|
||||
console.log(` 50% mastery: Adaptive ${adaptiveWins50}, Classic ${classicWins50}, Ties ${ties50}`)
|
||||
console.log(` 80% mastery: Adaptive ${adaptiveWins80}, Classic ${classicWins80}, Ties ${ties80}`)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
3001
apps/web/scripts/seedTestStudents.ts
Normal file
3001
apps/web/scripts/seedTestStudents.ts
Normal file
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)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user