Compare commits

..

2 Commits

Author SHA1 Message Date
semantic-release-bot
28fc0a14be chore(release): 4.67.2 [skip ci]
## [4.67.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.67.1...v4.67.2) (2025-10-23)

### Performance Improvements

* **complement-race:** increase train position update frequency to 60fps ([fffaf1d](fffaf1df1d))
2025-10-23 11:24:24 +00:00
Thomas Hallock
fffaf1df1d perf(complement-race): increase train position update frequency to 60fps
Increased update intervals from 50ms (20fps) to 16ms (60fps) for smoother
train movement without using react-spring animations. Changes applied to:

- Game logic loop (useSteamJourney.ts)
- Momentum/position updates (Provider.tsx)
- Position broadcasts for multiplayer (Provider.tsx)

This resolves the regression where react-spring animations caused guest
players' trains to freeze at their starting position in multiplayer games.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 06:23:02 -05:00
1432 changed files with 15556 additions and 523546 deletions

View File

@@ -181,254 +181,9 @@
"Bash(shasum:*)",
"Bash(open http://localhost:3000/arcade/matching)",
"Bash(echo:*)",
"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 )"
"Bash(npm run type-check:*)"
],
"deny": [],
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"sqlite"
]
}
}

View File

@@ -19,18 +19,12 @@ yarn-error.log*
# Testing
coverage
.nyc_output
**/__tests__
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
# IDE
.vscode
.idea
*.swp
*.swo
.claude
# OS
.DS_Store
@@ -40,8 +34,6 @@ Thumbs.db
README.md
docs/
*.md
# EXCEPTION: Include blog content markdown files
!apps/web/content/**/*.md
# Python cache
__pycache__
@@ -54,21 +46,7 @@ packages/core/.venv/
# Storybook
storybook-static
**/*.stories.tsx
**/*.stories.ts
.storybook
# Deployment files
nas-deployment/
DEPLOYMENT_PLAN.md
# SQLite database files (created at runtime)
**/data/*.db
**/data/*.db-shm
**/data/*.db-wal
# Build artifacts (rebuilt during Docker build)
**/dist
**/.next
**/build
**/styled-system
DEPLOYMENT_PLAN.md

View File

@@ -22,7 +22,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "18"
cache: "pnpm"
- name: Install dependencies

1
.gitignore vendored
View File

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

View File

@@ -1,16 +0,0 @@
{
"mcpServers": {
"sqlite": {
"command": "/Users/antialias/.nvm/versions/node/v20.19.3/bin/npx",
"args": [
"-y",
"mcp-server-sqlite-npx",
"/Users/antialias/projects/soroban-abacus-flashcards/apps/web/data/sqlite.db"
],
"env": {
"PATH": "/Users/antialias/.nvm/versions/node/v20.19.3/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin",
"NODE_PATH": "/Users/antialias/.nvm/versions/node/v20.19.3/lib/node_modules"
}
}
}
}

3
.npmrc
View File

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

99107
CHANGELOG.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,8 @@
# Multi-stage build for Soroban Abacus Flashcards
FROM node:20-alpine AS base
FROM node:18-alpine AS base
# 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 Python and build tools for better-sqlite3
RUN apk add --no-cache python3 py3-setuptools make g++
# Install pnpm and turbo
RUN npm install -g pnpm@9.15.4 turbo@1.10.0
@@ -28,7 +16,7 @@ COPY packages/core/client/node/package.json ./packages/core/client/node/
COPY packages/abacus-react/package.json ./packages/abacus-react/
COPY packages/templates/package.json ./packages/templates/
# Install ALL dependencies for build stage
# Install dependencies (will use .npmrc with hoisted mode)
RUN pnpm install --frozen-lockfile
# Builder stage
@@ -56,79 +44,21 @@ RUN cd apps/web && npx @pandacss/dev
# Build using turbo for apps/web and its dependencies
RUN turbo build --filter=@soroban/web
# Production dependencies stage - install only runtime dependencies
# IMPORTANT: Must use same base as runner stage for binary compatibility (better-sqlite3)
FROM node:20-slim AS deps
WORKDIR /app
# Install build tools temporarily for better-sqlite3 installation
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Install pnpm
RUN npm install -g pnpm@9.15.4
# Copy package files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json ./apps/web/
COPY packages/core/client/node/package.json ./packages/core/client/node/
COPY packages/abacus-react/package.json ./packages/abacus-react/
COPY packages/templates/package.json ./packages/templates/
# Install ONLY production dependencies
RUN pnpm install --frozen-lockfile --prod
# Typst builder stage - download and prepare typst binary
FROM node:20-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) && \
if [ "$ARCH" = "x86_64" ]; then \
TYPST_ARCH="x86_64-unknown-linux-musl"; \
elif [ "$ARCH" = "aarch64" ]; then \
TYPST_ARCH="aarch64-unknown-linux-musl"; \
else \
echo "Unsupported architecture: $ARCH" && exit 1; \
fi && \
TYPST_VERSION="v0.13.0" && \
wget -q "https://github.com/typst/typst/releases/download/${TYPST_VERSION}/typst-${TYPST_ARCH}.tar.xz" && \
tar -xf "typst-${TYPST_ARCH}.tar.xz" && \
mv "typst-${TYPST_ARCH}/typst" /usr/local/bin/typst && \
chmod +x /usr/local/bin/typst
# Production image
FROM node:20-slim AS runner
FROM node:18-alpine AS runner
WORKDIR /app
# Install ONLY runtime dependencies (no build tools)
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
qpdf \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy typst binary from typst-builder stage
COPY --from=typst-builder /usr/local/bin/typst /usr/local/bin/typst
# Install Python, pip, build tools for better-sqlite3, Typst, and qpdf (needed at runtime)
RUN apk add --no-cache python3 py3-pip py3-setuptools make g++ typst qpdf
# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy built Next.js application
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next ./apps/web/.next
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
# Copy blog content (markdown files)
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/content ./apps/web/content
# Copy Panda CSS generated styles
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/styled-system ./apps/web/styled-system
@@ -139,9 +69,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/dist ./apps/web/dist
# Copy database migrations
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/drizzle ./apps/web/drizzle
# Copy PRODUCTION node_modules only (no dev dependencies)
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=deps --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
# Copy node_modules (for dependencies)
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
# Copy core package (needed for Python flashcard generation scripts)
COPY --from=builder --chown=nextjs:nodejs /app/packages/core ./packages/core
@@ -149,9 +79,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/packages/core ./packages/core
# Copy templates package (needed for Typst templates)
COPY --from=builder --chown=nextjs:nodejs /app/packages/templates ./packages/templates
# Copy abacus-react package (needed for calendar generation scripts)
COPY --from=builder --chown=nextjs:nodejs /app/packages/abacus-react ./packages/abacus-react
# Install Python dependencies for flashcard generation
RUN pip3 install --no-cache-dir --break-system-packages -r packages/core/requirements.txt
@@ -162,14 +89,14 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./apps/web/
# Set up environment
WORKDIR /app/apps/web
# Create data directory for SQLite database and uploads
RUN mkdir -p data/uploads && chown -R nextjs:nodejs data
# Create data directory for SQLite database
RUN mkdir -p data && chown 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"]
CMD ["node", "server.js"]

View File

@@ -802,89 +802,13 @@ MIT License - see LICENSE file for details.
## This project uses DejaVu Sans font (included), which is released under a free license.
## 📚 Additional Documentation
### Web Application
**Location**: [`apps/web/`](./apps/web/)
**Documentation**: [`apps/web/README.md`](./apps/web/README.md)
The main Next.js web application containing tutorials, practice sessions, arcade games, and worksheet generation.
### Arcade Game System
**Location**: [`apps/web/src/arcade-games/`](./apps/web/src/arcade-games/)
**Overview**: [`apps/web/src/arcade-games/README.md`](./apps/web/src/arcade-games/README.md)
Modular, plugin-based architecture for building multiplayer arcade games with real-time synchronization.
**Available Games**:
- **[Know Your World](./apps/web/src/arcade-games/know-your-world/README.md)** - Geography quiz with precision controls for tiny regions ([Precision Controls Docs](./apps/web/src/arcade-games/know-your-world/PRECISION_CONTROLS.md))
- Number Guesser - Hot/cold guessing game
- Memory Quiz - Pattern matching challenge
- Matching Pairs - Memory card game
**Key Documentation**:
- **[Game SDK](./apps/web/src/arcade-games/README.md)** - How to create new arcade games
- **[Precision Controls](./apps/web/src/arcade-games/know-your-world/PRECISION_CONTROLS.md)** - Advanced cursor dampening and super zoom system
### Worksheet Generator
**Location**: [`apps/web/src/app/create/worksheets/`](./apps/web/src/app/create/worksheets/)
**Overview**: [`apps/web/src/app/create/worksheets/README.md`](./apps/web/src/app/create/worksheets/README.md)
Create customizable math worksheets with progressive difficulty, problem space validation, and Typst-powered PDF generation.
**Key Documentation**:
- **[Problem Generation Architecture](./apps/web/src/app/create/worksheets/PROBLEM_GENERATION_ARCHITECTURE.md)** - Complete technical deep-dive on generation algorithms, strategies, and edge cases
- **[User Warning Improvements](./apps/web/src/app/create/worksheets/USER_WARNING_IMPROVEMENTS.md)** - UX enhancement plan for problem space warnings
### Abacus React Component
**Package**: `@soroban/abacus-react`
**Documentation**: [`packages/abacus-react/README.md`](./packages/abacus-react/README.md)
**Storybook**: [Interactive Examples](https://antialias.github.io/soroban-abacus-flashcards/abacus-react/)
React component library for rendering interactive and static abacus visualizations.
### Decomposition Display
**Location**: [`apps/web/src/components/decomposition/`](./apps/web/src/components/decomposition/)
**Documentation**: [`apps/web/src/components/decomposition/README.md`](./apps/web/src/components/decomposition/README.md)
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
- **Abacus Coordination** - Bidirectional highlighting between decomposition and abacus
### Daily Practice System
**Location**: [`apps/web/docs/DAILY_PRACTICE_SYSTEM.md`](./apps/web/docs/DAILY_PRACTICE_SYSTEM.md)
**Entry Point**: `/practice` route in the web app
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
- **Worksheet Integration** - Generate worksheets based on student's current level
## 🚀 Active Development Projects
### Speed Complement Race Port (In Progress)
**Status**: Planning Complete, Ready to Implement
**Plan Document**: [`apps/web/COMPLEMENT_RACE_PORT_PLAN.md`](./apps/web/COMPLEMENT_RACE_PORT_PLAN.md)
**Source**: `packages/core/src/web_generator.py` (lines 10956-15113)
**Status**: Planning Complete, Ready to Implement
**Plan Document**: [`apps/web/COMPLEMENT_RACE_PORT_PLAN.md`](./apps/web/COMPLEMENT_RACE_PORT_PLAN.md)
**Source**: `packages/core/src/web_generator.py` (lines 10956-15113)
**Target**: `apps/web/src/app/games/complement-race/`
A comprehensive port of the sophisticated Speed Complement Race game from standalone HTML to Next.js. Features 3 game modes, 2 AI personalities with 82 unique commentary messages, adaptive difficulty, and multiple visualization systems.

View File

@@ -1,113 +0,0 @@
# Animation Patterns
## Spring-for-Speed, Manual-Integration-for-Angle Pattern
When animating continuous rotation where the **speed changes smoothly** but you need to **avoid position jumps**, use this pattern.
### 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
### The Solution: Decouple Speed and Angle
```typescript
import { animated, useSpringValue } from '@react-spring/web'
// 1. Spring for SPEED (this is what transitions smoothly)
const rotationSpeed = useSpringValue(0, {
config: { tension: 200, friction: 30 },
})
// 2. Spring value for ANGLE (we'll .set() this directly, no springing)
const rotationAngle = useSpringValue(0)
// 3. Update speed spring when target changes
useEffect(() => {
rotationSpeed.start(targetSpeedDegPerSec)
}, [targetSpeedDegPerSec, rotationSpeed])
// 4. requestAnimationFrame loop integrates angle from speed
useEffect(() => {
let lastTime = performance.now()
let frameId: number
const loop = (now: number) => {
const dt = (now - lastTime) / 1000 // seconds
lastTime = now
const speed = rotationSpeed.get() // deg/s from the spring
let angle = rotationAngle.get() + speed * dt // integrate
// Keep angle in reasonable range (prevent overflow)
if (angle >= 360000) angle -= 360000
if (angle < 0) angle += 360
// Direct set - no extra springing on angle itself
rotationAngle.set(angle)
frameId = requestAnimationFrame(loop)
}
frameId = requestAnimationFrame(loop)
return () => cancelAnimationFrame(frameId)
}, [rotationSpeed, rotationAngle])
// 5. Bind angle to animated element
<animated.svg
style={{
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
}}
>
{/* SVG content */}
</animated.svg>
```
### Why This Works
1. **Speed spring handles smooth transitions**: When target speed changes, the spring smoothly interpolates. No jumps.
2. **Manual integration preserves continuity**: `angle += speed * dt` always adds to the current angle. The angle never resets or jumps.
3. **Direct `.set()` avoids lag**: We're not asking the spring to animate the angle - we're directly setting it 60 times per second. No batching issues.
4. **`useSpringValue` enables binding**: Unlike a plain ref, `useSpringValue` can be bound to animated elements via `.to()`.
### Key Insights
- **Spring the derivative, integrate the value**: Speed is the derivative of angle. Spring the speed, integrate to get angle.
- **Never spring something you're updating 60fps**: The spring can't keep up. Use `.set()` instead of `.start()`.
- **Keep integration in rAF, not React effects**: React effects can skip frames or batch. rAF is reliable.
### When to Use This Pattern
- Rotating elements where rotation speed changes based on state
- Scrolling effects where scroll speed should transition smoothly
- Any continuous animation where the RATE of change should animate, not the value itself
### Anti-Patterns to Avoid
```typescript
// BAD: Calling start() in rAF loop
const loop = () => {
angle.start(currentAngle + speed * dt) // Will lag behind!
}
// BAD: CSS animation with dynamic duration
style={{
animation: `spin ${1/speed}s linear infinite` // Jumps on speed change!
}}
// BAD: Changing animation-delay to preserve position
style={{
animationDelay: `-${currentAngle / 360 * duration}s` // Unreliable!
}}
```

View File

@@ -33,7 +33,6 @@ In arcade sessions:
The arcade system supports three synchronization patterns:
#### Local Play (No Network Sync)
**Route**: Custom route or dedicated local page
**Use Case**: Practice, offline play, or games that should never be visible to others
@@ -45,7 +44,6 @@ The arcade system supports three synchronization patterns:
- State is NOT shared across the network, only within the browser session
#### Room-Based with Spectator Mode (RECOMMENDED PATTERN)
**Route**: `/arcade/room` (or use room context anywhere)
**Use Case**: Most arcade games - enables spectating even for single-player games
@@ -58,14 +56,12 @@ The arcade system supports three synchronization patterns:
- CAN have multiple ACTIVE PLAYERS per USER (networked + local multiplayer combined)
**✅ This is the PREFERRED pattern** - even for single-player games like Card Sorting, because:
- Enables spectator mode automatically
- Creates social experience ("watch me solve this!")
- No extra code needed
- Works seamlessly with multiplayer games too
#### Pure Multiplayer (Room-Only)
**Route**: `/arcade/room` with validation
**Use Case**: Games that REQUIRE multiple players (e.g., competitive battles)
@@ -78,15 +74,16 @@ The arcade system supports three synchronization patterns:
```typescript
// ❌ WRONG: Always checking for room data
const { roomData } = useRoomData();
useArcadeSession({ roomId: roomData?.id })<// This causes the bug!
// ✅ CORRECT: Explicit mode control via separate providers
LocalMemoryPairsProvider>;
{
/* Never passes roomId */
}
<RoomMemoryPairsProvider>{
/* Always passes roomId */
};
useArcadeSession({ roomId: roomData?.id }) < // This causes the bug!
// ✅ CORRECT: Explicit mode control via separate providers
LocalMemoryPairsProvider >
{
/* Never passes roomId */
} <
RoomMemoryPairsProvider >
{
/* Always passes roomId */
};
```
**Key principle:** The presence of a `roomId` parameter in `useArcadeSession` determines synchronization behavior:
@@ -304,7 +301,6 @@ sendMove({
Spectator mode is automatically enabled when using room-based sync (`roomId: roomData?.id`). Any room member who is not actively playing becomes a spectator and can watch the game in real-time.
**Key Benefits**:
- Creates social/collaborative experience even for single-player games
- "Watch me solve this!" engagement
- Learning by observation
@@ -366,7 +362,6 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
```
**Key Implementation Points**:
- Always check `if (!localPlayerId)` before allowing moves
- Return early or show "Spectating..." message
- Don't throw errors - spectating is a valid state
@@ -418,7 +413,6 @@ For games that support multiple players, show "Join Game" option:
#### 4. Real-Time Updates
Ensure spectators see smooth updates:
- Use optimistic UI updates (same as players)
- Show animations for state changes
- Display current player's moves as they happen
@@ -426,7 +420,6 @@ Ensure spectators see smooth updates:
### When to Use Spectator Mode
**✅ Use Spectator Mode (room-based sync) For**:
- Single-player puzzle games (Card Sorting, Sudoku, etc.)
- Turn-based competitive games (Matching Pairs Battle)
- Cooperative games (Memory Lightning)
@@ -435,7 +428,6 @@ Ensure spectators see smooth updates:
- Classroom settings (teacher demonstrates, students watch)
**❌ Avoid Spectator Mode (use local-only) For**:
- Private practice sessions
- Timed competitive games where watching gives unfair advantage
- Games with personal/sensitive content
@@ -506,32 +498,31 @@ The server must handle spectators correctly:
```typescript
// Validate move ownership
socket.on("game-move", ({ move, roomId }) => {
const session = getSession(roomId);
socket.on('game-move', ({ move, roomId }) => {
const session = getSession(roomId)
// Check if PLAYER making move is in the active players list
if (!session.activePlayers.includes(move.playerId)) {
return {
error: "PLAYER not in game - spectators cannot make moves",
};
error: 'PLAYER not in game - spectators cannot make moves'
}
}
// Check if USER owns this PLAYER
const playerOwner = getPlayerOwner(move.playerId);
const playerOwner = getPlayerOwner(move.playerId)
if (playerOwner !== socket.userId) {
return {
error: "USER does not own this PLAYER",
};
error: 'USER does not own this PLAYER'
}
}
// Valid move - apply and broadcast
const newState = validator.validateMove(session.state, move);
io.to(`game:${roomId}`).emit("state-update", newState); // ALL room members get update
});
const newState = validator.validateMove(session.state, move)
io.to(`game:${roomId}`).emit('state-update', newState) // ALL room members get update
})
```
**Key Server Logic**:
- Validate PLAYER is in `session.activePlayers`
- Validate USER owns PLAYER
- Broadcast to entire room (players + spectators)
@@ -540,37 +531,37 @@ socket.on("game-move", ({ move, roomId }) => {
### Testing Spectator Mode
```typescript
describe("Spectator Mode", () => {
it("should allow room members to spectate single-player games", () => {
describe('Spectator Mode', () => {
it('should allow room members to spectate single-player games', () => {
// Setup: USER A and USER B in same room
// Action: USER A starts Card Sorting (single-player)
// Assert: USER B receives game state updates
// Assert: USER B cannot make moves
// Assert: USER B sees USER A's card placements in real-time
});
})
it("should prevent spectators from making moves", () => {
it('should prevent spectators from making moves', () => {
// Setup: USER A playing, USER B spectating
// Action: USER B attempts to place a card
// Assert: Server rejects move (PLAYER not in activePlayers)
// Assert: Client UI disables controls for USER B
});
})
it("should show spectator indicator in UI", () => {
it('should show spectator indicator in UI', () => {
// Setup: USER B spectating USER A's game
// Assert: UI shows "Spectating [Player Name]" banner
// Assert: Interactive controls are disabled
// Assert: Game state is visible
});
})
it("should allow spectator to join next round", () => {
it('should allow spectator to join next round', () => {
// Setup: USER B spectating USER A's Card Sorting game
// Action: USER A finishes game, returns to setup
// Action: USER B starts new game
// Assert: USER A becomes spectator
// Assert: USER B becomes active player
});
});
})
})
```
### Migration Path

View File

@@ -7,14 +7,12 @@
**Purpose:** The main arcade landing page - displays the "Champion Arena"
**Key Components:**
- `ArcadeContent()` - Renders the main arcade interface
- Uses `EnhancedChampionArena` component which contains `GameSelector`
- The `GameSelector` displays all available games as cards
- `GameSelector` includes both legacy games and registry games
**Current Flow:**
1. User navigates to `/arcade`
2. Page renders `FullscreenProvider` wrapper
3. Displays `PageWithNav` with title "🏟️ Champion Arena"
@@ -25,7 +23,6 @@
8. For legacy games, URL would be direct to their page
**State Management:**
- `GameModeContext` provides player selection (emoji, name, color)
- `PageWithNav` wraps content and provides mini-nav with:
- Active player list
@@ -42,12 +39,10 @@
**Three States:**
### State 1: Loading
- Shows "Loading room..." message
- Waits for `useRoomData()` hook to resolve
### State 2: Game Selection UI (when `!roomData.gameName`)
- Shows large game selection buttons
- User clicks to select a game
- Calls `setRoomGame()` mutation to save selection to room
@@ -58,9 +53,8 @@
4. Game selection is persisted to the room database
### State 3: Game Display (when `roomData.gameName` is set)
- Checks game registry first via `hasGame(roomData.gameName)`
- If registry game:
- If registry game:
- Gets game definition via `getGame(roomData.gameName)`
- Renders: `<Provider><GameComponent /></Provider>`
- Provider and GameComponent come from game registry definition
@@ -69,13 +63,11 @@
- Currently only shows "Game not yet supported"
**Key Hook:**
- `useRoomData()` - Fetches current room from API and subscribes to socket updates
- Returns `roomData` with fields: `id`, `name`, `code`, `gameName`, `gameConfig`, `members`, `memberPlayers`
- Also returns `isLoading` boolean
**Navigation Flow:**
1. User navigates to `/arcade`
2. `GameCard` onClick calls `router.push('/arcade/room?game={gameName}')`
3. User arrives at `/arcade/room`
@@ -91,7 +83,6 @@
The "mini app nav" is actually a sophisticated component within the `PageWithNav` wrapper that intelligently shows different UI based on context:
**Components & Props:**
- `navTitle` - Current page title (e.g., "Champion Arena", "Choose Game", "Speed Complement Race")
- `navEmoji` - Icon emoji for current page
- `gameMode` - Computed from active player count: 'none' | 'single' | 'battle' | 'tournament'
@@ -105,7 +96,6 @@ The "mini app nav" is actually a sophisticated component within the `PageWithNav
**Three Display Modes:**
### Mode 1: Fullscreen Player Selection
- When `showFullscreenSelection === true`
- Displays:
- Large title with emoji
@@ -114,7 +104,6 @@ The "mini app nav" is actually a sophisticated component within the `PageWithNav
- Shows all inactive players for selection
### Mode 2: Solo Mode (NOT in room)
- When `roomInfo` is undefined
- Shows:
- **Game Title Section** (left side):
@@ -126,7 +115,6 @@ The "mini app nav" is actually a sophisticated component within the `PageWithNav
- `AddPlayerButton` - add more players
### Mode 3: Room Mode (IN a room)
- When `roomInfo` is defined
- Shows:
- **Hidden:** Game title section (display: none)
@@ -140,7 +128,6 @@ The "mini app nav" is actually a sophisticated component within the `PageWithNav
- Add player button (for local players only)
**Key Sub-Components:**
- `GameTitleMenu` - Menu for game options (setup, new game, quit)
- `GameModeIndicator` - Shows 🎯 Solo, ⚔️ Battle, 🏆 Tournament, 👥 Select
- `RoomInfo` - Displays room metadata
@@ -151,7 +138,6 @@ The "mini app nav" is actually a sophisticated component within the `PageWithNav
- `PendingInvitations` - Banner for room invitations
**State Management:**
- Lifted from `PageWithNav` to preserve state across remounts:
- `showPopover` / `setShowPopover` - AddPlayerButton popover state
- `activeTab` / `setActiveTab` - 'add' or 'invite' tab selection
@@ -196,7 +182,6 @@ User B: Sees same game selection (if set) or game selector (if not set)
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameSelector.tsx` (lines 1-112)
**How It Works:**
1. `GameSelector` component gets all games from both sources:
- Legacy `GAMES_CONFIG` (currently empty)
- Registry games via `getAllGames()`
@@ -213,7 +198,6 @@ User B: Sees same game selection (if set) or game selector (if not set)
**Two Game Systems:**
### Registry Games (NEW - Modular)
- Location: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/arcade-games/`
- File: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/lib/arcade/game-registry.ts`
- Examples: `complement-race`, `memory-quiz`, `matching`
@@ -221,14 +205,12 @@ User B: Sees same game selection (if set) or game selector (if not set)
- Games registered globally via `registerGame()` function
### Legacy Games (OLD)
- Location: Directly in `/app/arcade/` directory
- Examples: `/app/arcade/complement-race/page.tsx`
- Currently, only complement-race is partially migrated
- Direct URL structure: `/arcade/{gameName}/page.tsx`
**Game Config Structure (for display):**
```javascript
{
name: string, // Display name
@@ -249,11 +231,9 @@ User B: Sees same game selection (if set) or game selector (if not set)
## 6. Key Components Summary
### PageWithNav - Main Layout Wrapper
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/PageWithNav.tsx` (lines 1-192)
**Responsibilities:**
- Wraps all game/arcade pages
- Manages GameContextNav state (mini-nav)
- Handles player configuration dialog
@@ -261,7 +241,6 @@ User B: Sees same game selection (if set) or game selector (if not set)
- Renders top navigation bar via `AppNavBar`
**Key Props:**
- `navTitle` - Passed to GameContextNav
- `navEmoji` - Passed to GameContextNav
- `gameName` - Internal game name for API
@@ -271,16 +250,13 @@ User B: Sees same game selection (if set) or game selector (if not set)
- `children` - Page content
### AppNavBar - Top Navigation Bar
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/AppNavBar.tsx` (lines 1-625)
**Variants:**
- `full` - Standard navigation (default for non-game pages)
- `minimal` - Game navigation (auto-selected for `/arcade` and `/games`)
**Minimal Nav Features:**
- Hamburger menu (left) with:
- Site navigation (Home, Create, Guide, Games)
- Controls (Fullscreen, Exit Arcade)
@@ -289,32 +265,26 @@ User B: Sees same game selection (if set) or game selector (if not set)
- Fullscreen indicator badge
### EnhancedChampionArena - Main Arcade Display
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/EnhancedChampionArena.tsx` (lines 1-40)
**Responsibilities:**
- Container for game selector
- Full-height flex layout
- Passes configuration to `GameSelector`
### GameSelector - Game Grid
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameSelector.tsx` (lines 1-112)
**Responsibilities:**
- Fetches all games from registry
- Arranges in responsive grid
- Shows header "🎮 Available Games"
- Renders GameCard for each game
### GameCard - Individual Game Button
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameCard.tsx` (lines 1-241)
**Responsibilities:**
- Displays game with icon, name, description
- Shows feature chips and player count indicator
- Validates player count against game requirements
@@ -324,25 +294,21 @@ User B: Sees same game selection (if set) or game selector (if not set)
## 7. State Management
### GameModeContext
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/contexts/GameModeContext.tsx` (lines 1-325)
**Manages:**
- Local players (Map<string, Player>)
- Active players (Set<string>)
- Game mode (computed from active player count)
- Player CRUD operations (add, update, remove)
**Key Features:**
- Fetches players from user's local DB via `useUserPlayers()`
- Creates 4 default players if none exist
- When in room: merges room members' players (marked as isLocal: false)
- Syncs to room members via `notifyRoomOfPlayerUpdate()`
**Computed Values:**
- `activePlayerCount` - Size of activePlayers set
- `gameMode`:
- 1 player → 'single'
@@ -350,18 +316,15 @@ User B: Sees same game selection (if set) or game selector (if not set)
- 3+ players → 'tournament'
### useRoomData Hook
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/hooks/useRoomData.ts` (lines 1-450+)
**Manages:**
- Current room fetching via TanStack Query
- Socket.io real-time updates
- Room state (members, players, game name)
- Moderation events (kicked, banned, invitations)
**Key Operations:**
- `fetchCurrentRoom()` - GET `/api/arcade/rooms/current`
- `createRoomApi()` - POST `/api/arcade/rooms`
- `joinRoomApi()` - POST `/api/arcade/rooms/{id}/join`
@@ -369,7 +332,6 @@ User B: Sees same game selection (if set) or game selector (if not set)
- `setRoomGame()` - Updates room's gameName and gameConfig
**Socket Events:**
- `join-user-channel` - Personal notifications
- `join-room` - Subscribe to room updates
- `room-joined` - Refresh when entering room
@@ -398,22 +360,21 @@ User B: Sees same game selection (if set) or game selector (if not set)
```
**Query Parameters:**
- `/arcade/room?game={gameName}` - Optional game selection (parsed by GameCard)
## 9. Key Differences: /arcade vs /arcade/room
| Aspect | /arcade | /arcade/room |
| --------------------- | --------------------------- | ------------------------------------------------- |
| **Purpose** | Game selection hub | Active game display or selection within room |
| **Displays** | GameSelector with all games | Selected game OR game selector if no game in room |
| **Room Context** | Optional (can start solo) | Usually in a room (fetches via useRoomData) |
| **Navigation** | Click game → /arcade/room | Click game → Saves to room → Displays game |
| **GameContextNav** | Shows player selector | Shows room info when joined |
| **Player State** | Local only | Local + remote (room members) |
| **Exit Button** | Usually hidden | Shows "Exit Session" to return to /arcade |
| **Socket Connection** | Optional | Always connected (in room) |
| **Page Transition** | User controls | Driven by room state updates |
| Aspect | /arcade | /arcade/room |
|--------|---------|--------------|
| **Purpose** | Game selection hub | Active game display or selection within room |
| **Displays** | GameSelector with all games | Selected game OR game selector if no game in room |
| **Room Context** | Optional (can start solo) | Usually in a room (fetches via useRoomData) |
| **Navigation** | Click game → /arcade/room | Click game → Saves to room → Displays game |
| **GameContextNav** | Shows player selector | Shows room info when joined |
| **Player State** | Local only | Local + remote (room members) |
| **Exit Button** | Usually hidden | Shows "Exit Session" to return to /arcade |
| **Socket Connection** | Optional | Always connected (in room) |
| **Page Transition** | User controls | Driven by room state updates |
## 10. Planning the Merge (/arcade/room → /arcade)
@@ -453,7 +414,6 @@ User B: Sees same game selection (if set) or game selector (if not set)
**Merge Strategy Options:**
### Option A: Single Route with Modes
```
/arcade
├── Mode: browse (default, show GameSelector)
@@ -462,7 +422,6 @@ User B: Sees same game selection (if set) or game selector (if not set)
```
### Option B: Sub-routes
```
/arcade
├── /arcade (selector)
@@ -471,7 +430,6 @@ User B: Sees same game selection (if set) or game selector (if not set)
```
### Option C: Query-Parameter Driven
```
/arcade
├── /arcade (default - selector)

View File

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

View File

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

View File

@@ -1,236 +0,0 @@
# Blog Post Example Generation Pattern
## Overview
We have a **reusable pattern for generating single-problem worksheet examples** for blog posts. This ensures blog post examples use the **exact same rendering code** as the live UI preview, maintaining perfect consistency between documentation and the actual tool.
## The Pattern
### 1. Single Source of Truth
**Location**: `src/app/api/create/worksheets/addition/example/route.ts`
This API route contains the `generateExampleTypst()` function that:
- Takes display options (showCarryBoxes, showTenFrames, etc.)
- Takes specific addends (addend1, addend2)
- Generates a single compact problem using the same Typst helpers as full worksheets
- Compiles to SVG
### 2. Blog Post Generator Scripts
**Pattern**: Copy the `generateExampleTypst()` logic into a script that:
1. Imports `generateTypstHelpers` and `generateProblemStackFunction` from `typstHelpers.ts`
2. Defines examples with specific problems and display options
3. Generates Typst source for each example
4. Compiles to SVG using `typst compile`
5. Saves to `public/blog/[post-name]/`
### 3. Existing Examples
**Ten-frames blog post**:
- Script: `scripts/generateTenFrameExamples.ts`
- Output: `public/blog/ten-frame-examples/`
- Usage: Shows same problem (47 + 38) with/without ten-frames
- Blog post: `content/blog/ten-frames-for-regrouping.md`
**Difficulty progression blog post**:
- Script: `scripts/generateBlogExamples.ts`
- Output: `public/blog/difficulty-examples/`
- Usage: Shows same regrouping level with different scaffolding
- Blog post: `content/blog/beyond-easy-and-hard.md`
## Why This Pattern Matters
### Benefits
1. **Consistency**: Blog examples use the exact same rendering as the live tool
2. **Single Source of Truth**: One set of Typst helpers for both UI and docs
3. **Easy Updates**: When worksheet rendering changes, re-run scripts to update examples
4. **Specific Examples**: Can choose exact problems that demonstrate specific features
5. **Version Control**: Static SVGs committed to repo, no runtime generation needed
### Anti-Pattern (Don't Do This)
**Don't** manually create example SVGs in a design tool
**Don't** screenshot the live UI (inconsistent sizing, quality)
**Don't** duplicate the Typst rendering logic in separate files
**Don't** use the full worksheet generator for blog examples (creates 2x2 grids)
## How to Create New Blog Examples
### Step 1: Create Generator Script
```typescript
// scripts/generateYourFeatureExamples.ts
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
import {
generateTypstHelpers,
generateProblemStackFunction,
} from "../src/app/create/worksheets/addition/typstHelpers";
const outputDir = path.join(
process.cwd(),
"public",
"blog",
"your-feature-examples",
);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
interface ExampleOptions {
showCarryBoxes?: boolean;
showAnswerBoxes?: boolean;
showPlaceValueColors?: boolean;
showTenFrames?: boolean;
showProblemNumbers?: boolean;
fontSize?: number;
addend1: number;
addend2: number;
}
function generateExampleTypst(config: ExampleOptions): string {
const a = config.addend1;
const b = config.addend2;
const fontSize = config.fontSize || 16;
const cellSize = 0.45; // Larger than UI preview (0.35) for blog readability
const showCarries = config.showCarryBoxes ?? false;
const showAnswers = config.showAnswerBoxes ?? false;
const showColors = config.showPlaceValueColors ?? false;
const showNumbers = config.showProblemNumbers ?? false;
const showTenFrames = config.showTenFrames ?? false;
return String.raw`
#set page(width: auto, height: auto, margin: 12pt, fill: white)
#set text(size: ${fontSize}pt, font: "New Computer Modern Math")
#let heavy-stroke = 0.8pt
#let show-ten-frames-for-all = false
${generateTypstHelpers(cellSize)}
${generateProblemStackFunction(cellSize)}
#let a = ${a}
#let b = ${b}
#let aT = calc.floor(calc.rem(a, 100) / 10)
#let aO = calc.rem(a, 10)
#let bT = calc.floor(calc.rem(b, 100) / 10)
#let bO = calc.rem(b, 10)
#align(center + horizon)[
#problem-stack(
a, b, aT, aO, bT, bO,
${showNumbers ? "0" : "none"},
${showCarries},
${showAnswers},
${showColors},
${showTenFrames},
${showNumbers}
)
]
`;
}
const examples = [
{
filename: "example-1.svg",
description: "Your feature demonstrated",
options: {
addend1: 47,
addend2: 38,
showCarryBoxes: true,
showAnswerBoxes: true,
showPlaceValueColors: true,
showTenFrames: true,
showProblemNumbers: true,
},
},
// Add more examples...
] as const;
for (const example of examples) {
const typstSource = generateExampleTypst(example.options);
const svg = execSync("typst compile --format svg - -", {
input: typstSource,
encoding: "utf8",
maxBuffer: 2 * 1024 * 1024,
});
fs.writeFileSync(path.join(outputDir, example.filename), svg, "utf-8");
}
```
### Step 2: Run Generator
```bash
npx tsx scripts/generateYourFeatureExamples.ts
```
### Step 3: Use in Blog Post
```markdown
---
title: "Your Feature Title"
---
## Feature Overview
![Example showing feature](/blog/your-feature-examples/example-1.svg)
_Caption explaining what the example demonstrates._
```
## Tips for Good Examples
### Problem Selection
- **Choose problems that require the feature**: For ten-frames, use 7+8=15 (requires regrouping)
- **Use simple, clear numbers**: 47 + 38 is better than 387 + 694 for demonstrating basics
- **Show edge cases when relevant**: Double regrouping (57 + 68) shows ten-frames in both columns
### Display Options
- **Minimize non-essential scaffolding**: Turn off unrelated features to focus attention
- **Use consistent options across related examples**: Same colors, same carry boxes, etc.
- **Consider cell size**: Blog examples use 0.45in vs UI preview 0.35in for readability
### File Organization
- **One directory per blog post**: `public/blog/[post-slug]/`
- **Descriptive filenames**: `with-ten-frames.svg`, not `example1.svg`
- **Keep generator script**: Document what examples show and why
## Maintenance
### When to Regenerate Examples
- ✅ When `generateProblemStackFunction()` changes (new rendering logic)
- ✅ When `generateTypstHelpers()` changes (new visual styling)
- ✅ When Typst compiler updates (may affect rendering)
- ❌ When blog post text changes (examples are independent)
### Updating All Examples
```bash
# Regenerate all blog examples
npx tsx scripts/generateBlogExamples.ts
npx tsx scripts/generateTenFrameExamples.ts
# Add more as needed
```
## Reference Implementation
See `scripts/generateTenFrameExamples.ts` for a complete, documented example of this pattern.
Key features demonstrated:
- Clear header documentation explaining the pattern
- Reusable `generateExampleTypst()` function
- Declarative example definitions
- Helpful inline comments explaining problem choices
- Error handling for Typst compilation

View File

@@ -33,7 +33,6 @@ const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
```
**Actual Behavior (CORRECT)**:
- ✅ When a USER plays Card Sorting in a room, the game state SYNCS ACROSS THE ROOM NETWORK
- ✅ This enables **spectator mode** - other room members can watch the game in real-time
- ✅ Card Sorting is single-player (`maxPlayers: 1`), but spectators can watch and cheer
@@ -41,12 +40,10 @@ const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
- ✅ Creates social/collaborative experience ("Watch me solve this!")
**Supported By Architecture** (ARCADE_ARCHITECTURE.md, Spectator Mode section):
> Spectator mode is automatically enabled when using room-based sync (`roomId: roomData?.id`).
> Any room member who is not actively playing becomes a spectator and can watch the game in real-time.
>
> **✅ This is the PREFERRED pattern** - even for single-player games like Card Sorting, because:
>
> - Enables spectator mode automatically
> - Creates social experience ("watch me solve this!")
> - No extra code needed
@@ -57,30 +54,29 @@ const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
```typescript
// For single-player games WITH spectator mode support:
export function CardSortingProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId();
const { roomData } = useRoomData(); // ✅ Fetch room data for spectator mode
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData() // ✅ Fetch room data for spectator mode
const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
userId: viewerId || "",
roomId: roomData?.id, // ✅ Enable spectator mode - room members can watch
userId: viewerId || '',
roomId: roomData?.id, // ✅ Enable spectator mode - room members can watch
initialState: mergedInitialState,
applyMove: applyMoveOptimistically,
});
})
// Actions check for localPlayerId - spectators won't have one
const startGame = useCallback(() => {
if (!localPlayerId) {
console.warn("[CardSorting] No local player - spectating only");
return; // ✅ Spectators blocked from starting game
console.warn('[CardSorting] No local player - spectating only')
return // ✅ Spectators blocked from starting game
}
// ... send move
}, [localPlayerId, sendMove]);
}, [localPlayerId, sendMove])
}
```
**Why This Pattern is Used**:
This enables spectator mode as a first-class user experience. Room members can:
- Watch other players solve puzzles
- Learn strategies by observation
- Cheer and coach
@@ -105,7 +101,6 @@ memory-quiz/Provider.tsx: const { roomData } = useRoomData()
```
All providers pass `roomId: roomData?.id` to `useArcadeSession`. This means:
-**All games** support spectator mode automatically
-**Single-player games** (card-sorting) enable "watch me play" experience
-**Multiplayer games** (matching, memory-quiz, complement-race) support both players and spectators
@@ -123,14 +118,14 @@ All providers pass `roomId: roomData?.id` to `useArcadeSession`. This means:
The provider correctly uses `useGameMode()` to access active players:
```typescript
const { activePlayers, players } = useGameMode();
const { activePlayers, players } = useGameMode()
const localPlayerId = useMemo(() => {
return Array.from(activePlayers).find((id) => {
const player = players.get(id);
return player?.isLocal !== false;
});
}, [activePlayers, players]);
const player = players.get(id)
return player?.isLocal !== false
})
}, [activePlayers, players])
```
✅ Only includes players with `isActive = true`
@@ -144,18 +139,17 @@ const localPlayerId = useMemo(() => {
**Location**: Provider.tsx lines 383-491 (all move creators)
All moves correctly use:
- `playerId: localPlayerId` (PLAYER makes the move)
- `userId: viewerId || ''` (USER owns the session)
```typescript
// Example from startGame (lines 383-391)
sendMove({
type: "START_GAME",
playerId: localPlayerId, // ✅ PLAYER ID
userId: viewerId || "", // ✅ USER ID
type: 'START_GAME',
playerId: localPlayerId, // ✅ PLAYER ID
userId: viewerId || '', // ✅ USER ID
data: { playerMetadata, selectedCards },
});
})
```
✅ Follows USER/PLAYER distinction correctly
@@ -193,18 +187,14 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
Uses the modular game system correctly:
```typescript
export const cardSortingGame = defineGame<
CardSortingConfig,
CardSortingState,
CardSortingMove
>({
export const cardSortingGame = defineGame<CardSortingConfig, CardSortingState, CardSortingMove>({
manifest,
Provider: CardSortingProvider,
GameComponent,
validator: cardSortingValidator,
defaultConfig,
validateConfig: validateCardSortingConfig,
});
})
```
✅ Proper TypeScript generics
@@ -272,7 +262,6 @@ export function GameComponent() {
```
**Also Consider**:
- Show "Join Game" prompt during setup phase for spectators
- Display spectator count ("2 people watching")
- Add smooth real-time animations for spectators
@@ -284,14 +273,12 @@ export function GameComponent() {
All arcade games currently support spectator mode. Consider documenting this in each game's README:
**Games with Spectator Mode**:
-`card-sorting` - Single-player puzzle with spectators
-`matching` - Multiplayer battle with spectators
-`memory-quiz` - Cooperative with spectators
-`complement-race` - Competitive with spectators
**Documentation to Add**:
- How spectator mode works in each game
- Example scenarios (family game night, classroom)
- Best practices for spectator experience
@@ -303,28 +290,28 @@ All arcade games currently support spectator mode. Consider documenting this in
Following ARCADE_ARCHITECTURE.md Spectator Mode section, add tests:
```typescript
describe("Card Sorting - Spectator Mode", () => {
it("should sync state to spectators when USER plays in a room", async () => {
describe('Card Sorting - Spectator Mode', () => {
it('should sync state to spectators when USER plays in a room', async () => {
// Setup: USER A and USER B in same room
// Action: USER A plays Card Sorting
// Assert: USER B (spectator) sees card placements in real-time
// Assert: USER B cannot place cards (no localPlayerId)
});
})
it("should prevent spectators from making moves", () => {
it('should prevent spectators from making moves', () => {
// Setup: USER A playing, USER B spectating
// Action: USER B attempts to place card
// Assert: Action blocked (localPlayerId check)
// Assert: Server rejects if somehow sent
});
})
it("should allow spectator to play after current player finishes", () => {
it('should allow spectator to play after current player finishes', () => {
// Setup: USER A playing, USER B spectating
// Action: USER A finishes, USER B starts new game
// Assert: USER B becomes player
// Assert: USER A becomes spectator
});
});
})
})
```
---
@@ -332,7 +319,6 @@ describe("Card Sorting - Spectator Mode", () => {
### 4. Architecture Documentation
**✅ COMPLETED**: ARCADE_ARCHITECTURE.md has been updated with comprehensive spectator mode documentation:
- Added "SPECTATOR" to core terminology
- Documented three synchronization modes (Local, Room-Based with Spectator, Pure Multiplayer)
- Complete "Spectator Mode" section with:
@@ -380,7 +366,6 @@ Based on ARCADE_ARCHITECTURE.md Spectator Mode Pattern:
## Summary
The Card Sorting Challenge game is **correctly implemented** with:
- ✅ Active players (only `isActive = true` players participate)
- ✅ Player ID vs User ID distinction
- ✅ Validator pattern
@@ -393,7 +378,6 @@ The Card Sorting Challenge game is **correctly implemented** with:
**CORRECT**: Room sync enables spectator mode as a first-class feature
The `roomId: roomData?.id` pattern is **intentional and correct**:
1. ✅ Enables spectator mode automatically
2. ✅ Room members can watch games in real-time
3. ✅ Creates social/collaborative experience
@@ -401,7 +385,6 @@ The `roomId: roomData?.id` pattern is **intentional and correct**:
5. ✅ Follows ARCADE_ARCHITECTURE.md recommended pattern
**Recommended Enhancements** (not critical):
1. Add spectator UI indicators ("👀 Spectating...")
2. Disable controls visually for spectators
3. Add spectator mode tests

View File

@@ -1,449 +0,0 @@
# Card Sorting: Multiplayer & Spectator Features Plan
## Overview
Add collaborative and competitive multiplayer modes to the card-sorting game, plus enhanced spectator experience with real-time player indicators.
---
## 1. Core Feature: Player Emoji on Moving Cards
**When any player (including network players) moves a card, show their emoji on it.**
### Data Structure Changes
#### `CardPosition` type enhancement:
```typescript
export interface CardPosition {
cardId: string;
x: number; // % of viewport width (0-100)
y: number; // % of viewport height (0-100)
rotation: number; // degrees (-15 to 15)
zIndex: number;
draggedByPlayerId?: string; // NEW: ID of player currently dragging this card
}
```
### Implementation Details
1. **When starting drag (local player):**
- Set `draggedByPlayerId` to current player's ID
- Broadcast position update immediately with this field
2. **During drag:**
- Continue including `draggedByPlayerId` in position updates
- Other clients show the emoji overlay
3. **When ending drag:**
- Clear `draggedByPlayerId` (set to `undefined`)
- Broadcast final position without this field
4. **Visual indicator:**
- Show player emoji in top-right corner of card
- Semi-transparent background circle
- Small size (24-28px diameter)
- Positioned absolutely within card container
- Example styling:
```typescript
{
position: 'absolute',
top: '4px',
right: '4px',
width: '28px',
height: '28px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.9)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
border: '2px solid rgba(59, 130, 246, 0.6)',
zIndex: 10
}
```
5. **Access to player metadata:**
- Need to map `playerId` → `PlayerMetadata`
- Current state only has single `playerMetadata`
- For multiplayer, Provider needs to maintain `players: Map<string, PlayerMetadata>`
- Get from room members data
---
## 2. Spectator Mode UI Enhancements
### 2.1 Spectator Banner
**Top banner that clearly indicates spectator status**
```typescript
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: '48px',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
zIndex: 100,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
}}>
<div>👀 Spectating: {playerName} {playerEmoji}</div>
<div>Progress: {cardsPlaced}/{totalCards} cards placed</div>
</div>
```
### 2.2 Educational Mode Toggle
**Allow spectators to see the correct answer (for learning)**
- Toggle button in spectator banner
- When enabled: show faint green checkmarks on correctly positioned cards
- Don't show actual numbers unless player revealed them
### 2.3 Player Stats Sidebar
**Show real-time stats (optional, can collapse)**
- Time elapsed
- Cards placed vs. total
- Number of moves made
- Current accuracy (% of cards in correct relative order)
---
## 3. Collaborative Mode: "Team Sort"
### 3.1 Core Mechanics
- Multiple players share the same board and card set
- Anyone can move any card at any time
- Shared timer and shared score
- Team wins/loses together
### 3.2 State Changes
#### `CardSortingState` additions:
```typescript
export interface CardSortingState extends GameState {
// ... existing fields ...
gameMode: "solo" | "collaborative" | "competitive" | "relay"; // NEW
players: Map<string, PlayerMetadata>; // NEW: all active players
activePlayers: string[]; // NEW: players currently in game (not spectators)
}
```
### 3.3 Visual Indicators
1. **Colored cursors for each player:**
- Show a small colored dot/cursor at other players' pointer positions
- Color derived from player's emoji or assigned color
- Update positions via WebSocket (throttled to 30Hz)
2. **Card claiming indicator:**
- When player starts dragging, show their emoji on card (as per feature #1)
- Other players see animated emoji bouncing slightly
- Prevents confusion about who's moving what
3. **Activity feed (optional):**
- Small toast notifications for key actions
- "🎭 Bob placed card #3"
- "🦊 Alice revealed numbers"
- Auto-dismiss after 3 seconds
### 3.4 New Moves
```typescript
// In CardSortingMove union:
| {
type: 'JOIN_COLLABORATIVE_GAME'
playerId: string
userId: string
timestamp: number
data: {
playerMetadata: PlayerMetadata
}
}
| {
type: 'LEAVE_COLLABORATIVE_GAME'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
```
### 3.5 Scoring
- Same scoring algorithm but labeled as "Team Score"
- All players see the same results
- Leaderboard entry records all participants
---
## 4. Competitive Mode: "Race Sort"
### 4.1 Core Mechanics
- 2-4 players get the **same** card set
- Each player has their **own separate board**
- Race to finish first OR best score after time limit
- Live leaderboard shows current standings
### 4.2 State Architecture
**Problem:** Current state is single-player only.
**Solution:** Each player needs their own game state, but they're in the same room.
#### Option A: Separate Sessions
- Each competitive player creates their own session
- Room tracks all session IDs
- Client fetches all sessions and displays them
- **Pros:** Minimal changes to game logic
- **Cons:** Complex room management
#### Option B: Multi-Player State (RECOMMENDED)
```typescript
export interface CompetitiveGameState extends GameState {
gameMode: "competitive";
sharedCards: SortingCard[]; // Same cards for everyone
correctOrder: SortingCard[]; // Shared answer
playerBoards: Map<string, PlayerBoard>; // Each player's board state
gameStartTime: number;
gameEndTime: number | null;
winners: string[]; // Player IDs who completed, in order
}
export interface PlayerBoard {
playerId: string;
placedCards: (SortingCard | null)[];
cardPositions: CardPosition[];
availableCards: SortingCard[];
numbersRevealed: boolean;
completedAt: number | null;
scoreBreakdown: ScoreBreakdown | null;
}
```
### 4.3 UI Layout
**Split-screen view:**
```
┌─────────────────────────────────────┐
│ Leaderboard (top bar) │
├──────────────┬──────────────────────┤
│ │ │
│ Your Board │ Opponent Preview │
│ (full size) │ (smaller, ghosted) │
│ │ │
└──────────────┴──────────────────────┘
```
**Your board:**
- Normal interactive gameplay
- Full size, left side
**Opponent preview(s):**
- Right side (or bottom on mobile)
- Smaller scale (50-70% size)
- Semi-transparent cards
- Shows their real-time positions
- Can toggle between different opponents
**Leaderboard bar:**
```
┌─────────────────────────────────────┐
│ 🥇 Alice (5/8) • 🥈 You (4/8) • ... │
└─────────────────────────────────────┘
```
### 4.4 Spectator View for Competitive
- Can watch all players simultaneously
- Grid layout showing all boards
- Highlight current leader with gold border
---
## 5. Hybrid Mode: "Relay Sort" (Future)
### 5.1 Core Mechanics
- Players take turns (30-60 seconds each)
- Cumulative team score
- Can "pass" turn early
- Strategy: communicate via chat about optimal moves
### 5.2 Turn Management
```typescript
export interface RelayGameState extends GameState {
gameMode: "relay";
turnOrder: string[]; // Player IDs
currentTurnIndex: number;
turnStartTime: number;
turnDuration: number; // seconds
// ... rest similar to collaborative
}
```
---
## 6. Implementation Phases
### Phase 1: Foundation (Do First) ✅
- [x] Add `draggedByPlayerId` to `CardPosition`
- [x] Show player emoji on cards being dragged
- [x] Add `players` map to Provider context
- [x] Fetch room members and map to player metadata
### Phase 2: Spectator Enhancements
- [ ] Spectator banner component
- [ ] Educational mode toggle
- [ ] Stats sidebar (collapsible)
### Phase 3: Collaborative Mode
- [ ] Add `gameMode` to state and config
- [ ] Implement JOIN/LEAVE moves
- [ ] Colored cursor tracking
- [ ] Activity feed notifications
- [ ] Team scoring UI
### Phase 4: Competitive Mode
- [ ] Design multi-player state structure
- [ ] Refactor Provider for per-player boards
- [ ] Split-screen UI layout
- [ ] Live leaderboard
- [ ] Ghost opponent preview
- [ ] Winner determination
### Phase 5: Polish & Testing
- [ ] Mobile responsive layouts
- [ ] Performance optimization (many simultaneous players)
- [ ] Network resilience (handle disconnects)
- [ ] Accessibility (keyboard nav, screen readers)
---
## 7. Technical Considerations
### 7.1 WebSocket Message Frequency
- **Current:** Position updates throttled to 100ms (10Hz)
- **Collaborative:** May need higher frequency for smoothness
- **Recommendation:** 50ms (20Hz) for active drag, 100ms otherwise
### 7.2 State Synchronization
- Use optimistic updates for local player
- Reconcile with server state on conflicts
- Use timestamp-based conflict resolution
### 7.3 Player Disconnection Handling
- Collaborative: Keep their last positions, mark as "disconnected"
- Competitive: Pause their timer, allow rejoin within 60s
- Spectators: Just remove from viewer list
### 7.4 Security & Validation
- Server validates all moves (already done)
- Prevent players from seeing others' moves before they happen
- Rate limit position updates per player
---
## 8. Database Schema Changes
### New Tables
#### `competitive_rounds` (for competitive mode)
```sql
CREATE TABLE competitive_rounds (
id UUID PRIMARY KEY,
room_id UUID REFERENCES arcade_rooms(id),
started_at TIMESTAMP,
ended_at TIMESTAMP,
card_set JSON, -- The shared cards
winners JSON -- Array of player IDs in finish order
);
```
#### `player_round_results` (for competitive mode)
```sql
CREATE TABLE player_round_results (
id UUID PRIMARY KEY,
round_id UUID REFERENCES competitive_rounds(id),
player_id UUID,
score_breakdown JSON,
completed_at TIMESTAMP,
final_placement INTEGER -- 1st, 2nd, 3rd, etc.
);
```
---
## 9. Open Questions / Decisions Needed
1. **Collaborative: Card collision handling?**
- What if two players try to grab the same card simultaneously?
- Option A: First one wins, second gets error toast
- Option B: Allow both, last update wins
- **Recommendation:** Option A for better UX
2. **Competitive: Show opponents' exact positions?**
- Option A: Full transparency (see everything)
- Option B: Only show general progress (X/N cards placed)
- Option C: Ghost view (see positions but semi-transparent)
- **Recommendation:** Option C
3. **Spectator limit?**
- Max 10 spectators per game?
- Performance considerations for broadcasting positions
4. **Replay feature?**
- Record all position updates for playback?
- Storage implications?
- **Recommendation:** Future feature, not in initial scope
---
## 10. Success Metrics
- **Engagement:** % of games played in multiplayer vs. solo
- **Completion rate:** Do multiplayer games finish more/less often?
- **Session duration:** How long do multiplayer games last?
- **Return rate:** Do players come back for multiplayer?
- **Social sharing:** Do players invite friends?
---
## Next Steps
1. Get user approval on overall plan
2. Start with Phase 1 (player emoji on cards)
3. Build spectator UI enhancements (Phase 2)
4. Choose between Collaborative or Competitive for Phase 3/4
5. Iterate based on testing and feedback

View File

@@ -18,13 +18,11 @@ The Card Sorting Challenge correctly implements spectator mode functionally - sp
## Current Behavior
**Functional (Correct)**:
- ✅ Actions check `if (!localPlayerId) return` before sending moves
- ✅ Spectators cannot start game, place cards, or check solution
- ✅ Spectators receive real-time state updates
**Missing (UX Gap)**:
- ❌ No visual indicator that user is spectating
- ❌ Buttons appear clickable but don't respond
- ❌ No context about whose game is being watched
@@ -35,46 +33,40 @@ The Card Sorting Challenge correctly implements spectator mode functionally - sp
## Enhancement 1: Expose `localPlayerId` in Context
### Location
`/src/arcade-games/card-sorting/Provider.tsx`
### Changes
**Add to `CardSortingContextValue` interface** (line 14):
```typescript
interface CardSortingContextValue {
state: CardSortingState;
state: CardSortingState
// Actions
startGame: () => void;
placeCard: (cardId: string, position: number) => void;
insertCard: (cardId: string, insertPosition: number) => void;
removeCard: (position: number) => void;
checkSolution: () => void;
revealNumbers: () => void;
goToSetup: () => void;
resumeGame: () => void;
setConfig: (
field: "cardCount" | "showNumbers" | "timeLimit",
value: unknown,
) => void;
exitSession: () => void;
startGame: () => void
placeCard: (cardId: string, position: number) => void
insertCard: (cardId: string, insertPosition: number) => void
removeCard: (position: number) => void
checkSolution: () => void
revealNumbers: () => void
goToSetup: () => void
resumeGame: () => void
setConfig: (field: 'cardCount' | 'showNumbers' | 'timeLimit', value: unknown) => void
exitSession: () => void
// Computed
canCheckSolution: boolean;
placedCount: number;
elapsedTime: number;
hasConfigChanged: boolean;
canResumeGame: boolean;
canCheckSolution: boolean
placedCount: number
elapsedTime: number
hasConfigChanged: boolean
canResumeGame: boolean
// UI state
selectedCardId: string | null;
selectCard: (cardId: string | null) => void;
localPlayerId: string | undefined; // ✨ NEW: Expose for spectator checks
isSpectating: boolean; // ✨ NEW: Derived flag for convenience
selectedCardId: string | null
selectCard: (cardId: string | null) => void
localPlayerId: string | undefined // ✨ NEW: Expose for spectator checks
isSpectating: boolean // ✨ NEW: Derived flag for convenience
}
```
**Update context value** (line 527):
```typescript
const contextValue: CardSortingContextValue = {
state,
@@ -98,13 +90,12 @@ const contextValue: CardSortingContextValue = {
// UI state
selectedCardId,
selectCard: setSelectedCardId,
localPlayerId, // ✨ NEW
isSpectating: !localPlayerId, // ✨ NEW: Convenience flag
};
localPlayerId, // ✨ NEW
isSpectating: !localPlayerId, // ✨ NEW: Convenience flag
}
```
### Rationale
- Components need `localPlayerId` to determine spectator vs player state
- `isSpectating` is a convenience flag to avoid `!localPlayerId` checks everywhere
- Makes spectator mode a first-class concept in the API
@@ -114,13 +105,11 @@ const contextValue: CardSortingContextValue = {
## Enhancement 2: Spectator Indicator Banner
### Location
`/src/arcade-games/card-sorting/components/GameComponent.tsx`
### Visual Design
**Banner Appearance**:
```
┌─────────────────────────────────────────────────────┐
│ 👀 Spectating Alice 👧's game │
@@ -129,7 +118,6 @@ const contextValue: CardSortingContextValue = {
```
**Styling**:
- Background: `rgba(59, 130, 246, 0.1)` (soft blue, semi-transparent)
- Border: `1px solid rgba(59, 130, 246, 0.3)` (blue.500 with opacity)
- Border radius: `8px`
@@ -143,7 +131,6 @@ const contextValue: CardSortingContextValue = {
### Implementation
**Add banner component**:
```typescript
// Add after existing imports
import { useCardSorting } from '../Provider'
@@ -264,13 +251,11 @@ export function GameComponent() {
### Behavior
**Show Banner When**:
-`isSpectating === true` (no local player)
-`state.gamePhase === 'playing'` OR `state.gamePhase === 'results'`
- ❌ NOT during setup phase (handled separately below)
**Hide Banner When**:
- User has an active local player
- Game is in setup phase (use setup phase spectator prompt instead)
@@ -331,9 +316,7 @@ export function SetupPhase() {
## Enhancement 3: Visual Disabled States
### Location
All interactive components in:
- `/src/arcade-games/card-sorting/components/SetupPhase.tsx`
- `/src/arcade-games/card-sorting/components/PlayingPhase.tsx`
- `/src/arcade-games/card-sorting/components/ResultsPhase.tsx`
@@ -341,23 +324,21 @@ All interactive components in:
### Visual Design
**Disabled Button Styling**:
```typescript
const disabledStyles = {
opacity: 0.5,
cursor: "not-allowed",
pointerEvents: "none", // Prevent all interactions
};
cursor: 'not-allowed',
pointerEvents: 'none', // Prevent all interactions
}
```
**Disabled Card Styling**:
```typescript
const disabledCardStyles = {
opacity: 0.6,
cursor: "default",
pointerEvents: "none",
};
cursor: 'default',
pointerEvents: 'none',
}
```
### Implementation by Phase
@@ -367,7 +348,6 @@ const disabledCardStyles = {
**File**: `/src/arcade-games/card-sorting/components/SetupPhase.tsx`
**Changes**:
```typescript
export function SetupPhase() {
const {
@@ -462,7 +442,6 @@ export function SetupPhase() {
**File**: `/src/arcade-games/card-sorting/components/PlayingPhase.tsx`
**Changes**:
```typescript
export function PlayingPhase() {
const {
@@ -588,7 +567,6 @@ export function PlayingPhase() {
**File**: `/src/arcade-games/card-sorting/components/ResultsPhase.tsx`
**Changes**:
```typescript
export function ResultsPhase() {
const {
@@ -645,7 +623,6 @@ export function ResultsPhase() {
## Enhancement 4: Spectator Mode Tests
### Location
Create new file: `/src/arcade-games/card-sorting/__tests__/spectator-mode.test.tsx`
### Test Suite
@@ -917,27 +894,26 @@ describe('Card Sorting - Spectator Mode', () => {
## Enhancement 5: Player Ownership Tests
### Location
Create new file: `/src/arcade-games/card-sorting/__tests__/player-ownership.test.tsx`
### Test Suite
```typescript
import { describe, it, expect } from "vitest";
import { CardSortingValidator } from "../Validator";
import type { CardSortingState, CardSortingMove } from "../types";
import { describe, it, expect } from 'vitest'
import { CardSortingValidator } from '../Validator'
import type { CardSortingState, CardSortingMove } from '../types'
const validator = new CardSortingValidator();
const validator = new CardSortingValidator()
describe("Card Sorting - Player Ownership Validation", () => {
describe('Card Sorting - Player Ownership Validation', () => {
const createMockState = (): CardSortingState => ({
gamePhase: "playing",
playerId: "player_alice",
gamePhase: 'playing',
playerId: 'player_alice',
playerMetadata: {
id: "player_alice",
name: "Alice",
emoji: "👧",
userId: "user_123",
id: 'player_alice',
name: 'Alice',
emoji: '👧',
userId: 'user_123',
},
cardCount: 8,
showNumbers: true,
@@ -945,147 +921,147 @@ describe("Card Sorting - Player Ownership Validation", () => {
gameStartTime: Date.now(),
gameEndTime: null,
selectedCards: [
{ id: "card_1", number: 1, abacus: null },
{ id: "card_2", number: 2, abacus: null },
{ id: 'card_1', number: 1, abacus: null },
{ id: 'card_2', number: 2, abacus: null },
],
correctOrder: [
{ id: "card_1", number: 1, abacus: null },
{ id: "card_2", number: 2, abacus: null },
{ id: 'card_1', number: 1, abacus: null },
{ id: 'card_2', number: 2, abacus: null },
],
availableCards: [
{ id: "card_1", number: 1, abacus: null },
{ id: "card_2", number: 2, abacus: null },
{ id: 'card_1', number: 1, abacus: null },
{ id: 'card_2', number: 2, abacus: null },
],
placedCards: new Array(8).fill(null),
selectedCardId: null,
numbersRevealed: false,
scoreBreakdown: null,
});
})
describe("Player ID Validation", () => {
it("should accept move from correct player", () => {
const state = createMockState();
describe('Player ID Validation', () => {
it('should accept move from correct player', () => {
const state = createMockState()
const move: CardSortingMove = {
type: "PLACE_CARD",
playerId: "player_alice",
userId: "user_123",
data: { cardId: "card_1", position: 0 },
};
type: 'PLACE_CARD',
playerId: 'player_alice',
userId: 'user_123',
data: { cardId: 'card_1', position: 0 },
}
const result = validator.validateMove(state, move, {
activePlayers: ["player_alice"],
playerOwnership: { player_alice: "user_123" },
});
activePlayers: ['player_alice'],
playerOwnership: { player_alice: 'user_123' },
})
expect(result.valid).toBe(true);
});
expect(result.valid).toBe(true)
})
it("should reject move from player not in active players", () => {
const state = createMockState();
it('should reject move from player not in active players', () => {
const state = createMockState()
const move: CardSortingMove = {
type: "PLACE_CARD",
playerId: "player_bob", // Not in activePlayers
userId: "user_456",
data: { cardId: "card_1", position: 0 },
};
type: 'PLACE_CARD',
playerId: 'player_bob', // Not in activePlayers
userId: 'user_456',
data: { cardId: 'card_1', position: 0 },
}
const result = validator.validateMove(state, move, {
activePlayers: ["player_alice"], // Only Alice is active
activePlayers: ['player_alice'], // Only Alice is active
playerOwnership: {
player_alice: "user_123",
player_bob: "user_456",
player_alice: 'user_123',
player_bob: 'user_456',
},
});
})
expect(result.valid).toBe(false);
expect(result.error).toContain("PLAYER not in game");
});
expect(result.valid).toBe(false)
expect(result.error).toContain('PLAYER not in game')
})
it("should reject move when user does not own player", () => {
const state = createMockState();
it('should reject move when user does not own player', () => {
const state = createMockState()
const move: CardSortingMove = {
type: "PLACE_CARD",
playerId: "player_alice",
userId: "user_456", // Wrong user ID
data: { cardId: "card_1", position: 0 },
};
type: 'PLACE_CARD',
playerId: 'player_alice',
userId: 'user_456', // Wrong user ID
data: { cardId: 'card_1', position: 0 },
}
const result = validator.validateMove(state, move, {
activePlayers: ["player_alice"],
playerOwnership: { player_alice: "user_123" }, // Alice owned by user_123
});
activePlayers: ['player_alice'],
playerOwnership: { player_alice: 'user_123' }, // Alice owned by user_123
})
expect(result.valid).toBe(false);
expect(result.error).toContain("USER does not own this PLAYER");
});
expect(result.valid).toBe(false)
expect(result.error).toContain('USER does not own this PLAYER')
})
it("should reject move from spectator (no player ownership)", () => {
const state = createMockState();
it('should reject move from spectator (no player ownership)', () => {
const state = createMockState()
const move: CardSortingMove = {
type: "START_GAME",
playerId: "player_spectator",
userId: "user_999",
type: 'START_GAME',
playerId: 'player_spectator',
userId: 'user_999',
data: {
playerMetadata: {
id: "player_spectator",
name: "Spectator",
emoji: "👀",
userId: "user_999",
id: 'player_spectator',
name: 'Spectator',
emoji: '👀',
userId: 'user_999',
},
selectedCards: [],
},
};
}
const result = validator.validateMove(state, move, {
activePlayers: ["player_alice"], // Spectator not in active players
activePlayers: ['player_alice'], // Spectator not in active players
playerOwnership: {
player_alice: "user_123",
player_spectator: "user_999",
player_alice: 'user_123',
player_spectator: 'user_999',
},
});
})
expect(result.valid).toBe(false);
expect(result.error).toContain("PLAYER not in game");
});
});
expect(result.valid).toBe(false)
expect(result.error).toContain('PLAYER not in game')
})
})
describe("Single Player Game Constraints", () => {
it("should allow only one active player in the game", () => {
const state = createMockState();
describe('Single Player Game Constraints', () => {
it('should allow only one active player in the game', () => {
const state = createMockState()
// Card Sorting is single-player (maxPlayers: 1)
// If somehow multiple players try to join, validator should reject
const move: CardSortingMove = {
type: "START_GAME",
playerId: "player_bob",
userId: "user_456",
type: 'START_GAME',
playerId: 'player_bob',
userId: 'user_456',
data: {
playerMetadata: {
id: "player_bob",
name: "Bob",
emoji: "👦",
userId: "user_456",
id: 'player_bob',
name: 'Bob',
emoji: '👦',
userId: 'user_456',
},
selectedCards: [],
},
};
}
// State already has player_alice playing
const result = validator.validateMove(state, move, {
activePlayers: ["player_alice", "player_bob"], // Two active players
activePlayers: ['player_alice', 'player_bob'], // Two active players
playerOwnership: {
player_alice: "user_123",
player_bob: "user_456",
player_alice: 'user_123',
player_bob: 'user_456',
},
});
})
// Should reject if game is single-player only
// (This depends on validator implementation)
expect(result.valid).toBe(false);
});
});
});
expect(result.valid).toBe(false)
})
})
})
```
---
@@ -1093,14 +1069,12 @@ describe("Card Sorting - Player Ownership Validation", () => {
## Implementation Checklist
### Phase 1: Context Updates
- [ ] Add `localPlayerId` to `CardSortingContextValue` interface
- [ ] Add `isSpectating` to `CardSortingContextValue` interface
- [ ] Expose both in context value object
- [ ] Verify hook exports work correctly
### Phase 2: Spectator Indicators
- [ ] Add spectator banner to `GameComponent.tsx`
- [ ] Add setup phase spectator prompt to `SetupPhase.tsx`
- [ ] Test banner appears for spectators
@@ -1108,7 +1082,6 @@ describe("Card Sorting - Player Ownership Validation", () => {
- [ ] Test player name/emoji displayed correctly
### Phase 3: Disabled States
- [ ] Update `SetupPhase.tsx` buttons with disabled state
- [ ] Update `PlayingPhase.tsx` cards and buttons with disabled state
- [ ] Update `ResultsPhase.tsx` buttons with disabled state
@@ -1116,7 +1089,6 @@ describe("Card Sorting - Player Ownership Validation", () => {
- [ ] Test interactions actually blocked
### Phase 4: Testing
- [ ] Create spectator mode test file
- [ ] Write spectator indicator tests
- [ ] Write disabled controls tests
@@ -1128,7 +1100,6 @@ describe("Card Sorting - Player Ownership Validation", () => {
- [ ] All tests pass
### Phase 5: Quality & Deploy
- [ ] Run `npm run pre-commit` (format, lint, type-check)
- [ ] Manual testing: Join room as spectator
- [ ] Manual testing: Verify banner appears
@@ -1143,7 +1114,6 @@ describe("Card Sorting - Player Ownership Validation", () => {
## Visual Examples
### Spectating During Playing Phase
```
┌───────────────────────────────────────────────────────┐
│ 👀 Spectating Alice 👧's game │
@@ -1164,7 +1134,6 @@ describe("Card Sorting - Player Ownership Validation", () => {
```
### Spectating During Setup Phase
```
┌───────────────────────────────────────────────────────┐
│ 👤 Add a Player to Start │
@@ -1186,27 +1155,23 @@ describe("Card Sorting - Player Ownership Validation", () => {
## Success Criteria
**User Experience**:
- Spectators immediately know they're watching, not playing
- All interactive controls clearly disabled
- Spectators can see whose game they're watching
- Clear call-to-action to add a player to join
**Functional**:
- No moves sent from spectators (existing behavior maintained)
- Real-time state updates visible to spectators
- Context correctly exposes spectator state
**Code Quality**:
- All tests pass
- TypeScript types correct
- Pre-commit checks pass
- No regressions in player functionality
**Accessibility**:
- Disabled buttons use `disabled` attribute (not just styling)
- Screen readers announce disabled state
- Color contrast meets WCAG AA standards

View File

@@ -1,608 +0,0 @@
# 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.

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,6 @@ I used the **correct modular game pattern** (useArcadeSession) but **threw away
### The Correct Pattern (Used by ALL Modular Games)
**Pattern: useArcadeSession** (from GAME_MIGRATION_PLAYBOOK.md)
```typescript
// Uses useArcadeSession with action creators
export function YourGameProvider({ children }) {
@@ -44,7 +43,6 @@ export function YourGameProvider({ children }) {
```
**Used by**:
- Number Guesser ✅
- Matching ✅
- Memory Quiz ✅
@@ -66,7 +64,6 @@ export function YourGameProvider({ children }) {
**Game.tsx** - Created a simple quiz UI from scratch instead of using existing components:
**What I created (WRONG)**:
```typescript
// Simple number pad quiz
{currentQuestion && (
@@ -80,7 +77,6 @@ export function YourGameProvider({ children }) {
```
**What I should have used (CORRECT)**:
```typescript
// Existing sophisticated UI from src/app/arcade/complement-race/components/
- ComplementRaceGame.tsx // Main game container
@@ -110,7 +106,6 @@ The Complement Race Migration Plan Phase 4 mentioned `useSocketSync` and preserv
## What I Actually Did (Wrong)
**CORRECT**:
- Created `Validator.ts` (~700 lines of server-side game logic)
- Created `types.ts` with proper TypeScript types
- Registered in `validators.ts` and `game-registry.ts`
@@ -119,7 +114,6 @@ The Complement Race Migration Plan Phase 4 mentioned `useSocketSync` and preserv
- Disabled debug logging
**COMPLETELY WRONG**:
- Created `Provider.tsx` using Pattern A (useArcadeSession)
- Threw away existing reducer with 30+ action types
- Created `Game.tsx` with simple quiz UI
@@ -136,7 +130,6 @@ The Complement Race Migration Plan Phase 4 mentioned `useSocketSync` and preserv
## What Needs to Happen
### KEEP (Correct Implementation) ✅
1. `src/arcade-games/complement-race/Provider.tsx` ✅ (Actually correct!)
2. `src/arcade-games/complement-race/Validator.ts`
3. `src/arcade-games/complement-race/types.ts`
@@ -145,11 +138,9 @@ The Complement Race Migration Plan Phase 4 mentioned `useSocketSync` and preserv
6. Test file fixes ✅
### DELETE (Wrong Implementation) ❌
1. `src/arcade-games/complement-race/Game.tsx` ❌ (Simple quiz UI)
### UPDATE (Use Existing Components) ✏️
1. `src/arcade-games/complement-race/index.tsx`:
- Change `GameComponent` from new `Game.tsx` to existing `ComplementRaceGame`
- Import from `@/app/arcade/complement-race/components/ComplementRaceGame`
@@ -186,7 +177,6 @@ export const complementRaceGame = defineGame<...>({
**Challenge**: Existing UI components use `dispatch({ type: 'ACTION' })` but Provider exposes `startGame()`, `submitAnswer()`, etc.
**Solutions**:
1. Update components to use action creators (preferred)
2. Add compatibility layer in Provider that exposes `dispatch`
3. Create wrapper components

View File

@@ -23,7 +23,6 @@ Speed Complement Race is currently a sophisticated single-player game with three
## Current State Analysis
### What We Have
- ✅ Complex single-player game with 3 modes
- ✅ Advanced adaptive difficulty system
- ✅ AI opponent system with personalities
@@ -33,7 +32,6 @@ Speed Complement Race is currently a sophisticated single-player game with three
- ✅ Sound effects and visual feedback
### What's Missing
- ❌ Multiplayer support (max players: 1)
- ❌ Socket integration
- ❌ Validator registration in modular system
@@ -61,7 +59,6 @@ Game Components (existing UI)
```
### Key Principles
1. **Preserve existing gameplay** - Keep all three modes working
2. **Maintain UI/UX quality** - All animations, sounds, visuals stay intact
3. **Support both single and multiplayer** - AI opponents + human players
@@ -75,27 +72,19 @@ Game Components (existing UI)
## Phase 1: Configuration & Type System ✓
### 1.1 Define ComplementRaceGameConfig
**File**: `src/lib/game-configs.ts`
```typescript
export interface ComplementRaceGameConfig {
// Game Style (which mode)
style: "practice" | "sprint" | "survival";
style: 'practice' | 'sprint' | 'survival';
// Question Settings
mode: "friends5" | "friends10" | "mixed";
complementDisplay: "number" | "abacus" | "random";
mode: 'friends5' | 'friends10' | 'mixed';
complementDisplay: 'number' | 'abacus' | 'random';
// Difficulty
timeoutSetting:
| "preschool"
| "kindergarten"
| "relaxed"
| "slow"
| "normal"
| "fast"
| "expert";
timeoutSetting: 'preschool' | 'kindergarten' | 'relaxed' | 'slow' | 'normal' | 'fast' | 'expert';
// AI Settings
enableAI: boolean;
@@ -114,10 +103,10 @@ export interface ComplementRaceGameConfig {
}
export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = {
style: "practice",
mode: "mixed",
complementDisplay: "random",
timeoutSetting: "normal",
style: 'practice',
mode: 'mixed',
complementDisplay: 'random',
timeoutSetting: 'normal',
enableAI: true,
aiOpponentCount: 2,
maxPlayers: 1,
@@ -129,11 +118,9 @@ export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = {
```
### 1.2 Disable Debug Logging
**File**: `src/app/arcade/complement-race/hooks/useSteamJourney.ts`
Change:
```typescript
const DEBUG_PASSENGER_BOARDING = false; // was true
```
@@ -143,11 +130,9 @@ const DEBUG_PASSENGER_BOARDING = false; // was true
## Phase 2: Validator Implementation ✓
### 2.1 Create ComplementRaceValidator
**File**: `src/lib/validators/ComplementRaceValidator.ts`
**Responsibilities**:
- Validate player answers
- Generate questions
- Manage game state
@@ -155,33 +140,20 @@ const DEBUG_PASSENGER_BOARDING = false; // was true
- Synchronize multiplayer state
**Key Methods**:
```typescript
class ComplementRaceValidator {
getInitialState(config: ComplementRaceGameConfig): GameState;
getNewQuestion(state: GameState): ComplementQuestion;
validateAnswer(
state: GameState,
playerId: string,
answer: number,
): ValidationResult;
updatePlayerProgress(
state: GameState,
playerId: string,
correct: boolean,
): GameState;
checkWinCondition(state: GameState): {
winner: string | null;
gameOver: boolean;
};
updateAIPositions(state: GameState, deltaTime: number): GameState;
serializeState(state: GameState): SerializedState;
deserializeState(serialized: SerializedState): GameState;
getInitialState(config: ComplementRaceGameConfig): GameState
getNewQuestion(state: GameState): ComplementQuestion
validateAnswer(state: GameState, playerId: string, answer: number): ValidationResult
updatePlayerProgress(state: GameState, playerId: string, correct: boolean): GameState
checkWinCondition(state: GameState): { winner: string | null, gameOver: boolean }
updateAIPositions(state: GameState, deltaTime: number): GameState
serializeState(state: GameState): SerializedState
deserializeState(serialized: SerializedState): GameState
}
```
**State Structure**:
```typescript
interface MultiplayerGameState {
// Configuration
@@ -209,7 +181,7 @@ interface MultiplayerGameState {
progress: Map<playerId, number>; // 0-100% or lap count
// Game Status
phase: "waiting" | "countdown" | "playing" | "finished";
phase: 'waiting' | 'countdown' | 'playing' | 'finished';
winner: string | null;
startTime: number | null;
@@ -235,11 +207,9 @@ interface PlayerState {
## Phase 3: Socket Server Integration ✓
### 3.1 Register Game Handler
**File**: `src/services/socket-server.ts`
Add to game session management:
```typescript
case 'complement-race':
validator = new ComplementRaceValidator();
@@ -249,13 +219,11 @@ case 'complement-race':
### 3.2 Socket Events
**Client → Server**:
- `game:answer` - Submit answer for current question
- `game:ready` - Player ready to start
- `game:settings-change` - Update game config (host only)
**Server → Client**:
- `game:state-update` - Full state sync
- `game:question-new` - New question generated
- `game:answer-result` - Answer validation result
@@ -266,13 +234,11 @@ case 'complement-race':
### 3.3 Real-time Synchronization Strategy
**State Updates**:
- Full state broadcast every 200ms (AI updates)
- Instant broadcasts on player actions (answers, ready status)
- Delta compression for large states (sprint mode passengers)
**Race Condition Handling**:
- Server is source of truth
- Client predictions for smooth animations
- Rollback on server correction
@@ -282,11 +248,9 @@ case 'complement-race':
## Phase 4: Room Provider & Configuration ✓
### 4.1 Create RoomComplementRaceProvider
**File**: `src/app/arcade/complement-race/context/RoomComplementRaceProvider.tsx`
Similar to existing `ComplementRaceProvider` but:
- Accepts `roomCode` prop
- Loads saved config from arcade room state
- Merges saved config with defaults
@@ -327,11 +291,9 @@ export function RoomComplementRaceProvider({
```
### 4.2 Update Arcade Room Store
**File**: `src/app/arcade/stores/arcade-room-store.ts`
Ensure complement-race config is saved:
```typescript
updateGameConfig: (gameName: string, config: Partial<GameConfig>) => {
set((state) => {
@@ -352,14 +314,12 @@ updateGameConfig: (gameName: string, config: Partial<GameConfig>) => {
**Core Concept**: ONE railroad with ONE set of passengers. Players compete to pick them up and deliver them first.
#### Shared Game Board
- All players see the SAME track with SAME stations
- 6-8 passengers spawn per route at various stations
- Once a player picks up a passenger, it disappears for EVERYONE
- Real competition for limited resources
#### Visual Design: Ghost Trains
```
Your train: 🚂🟦 Full opacity (100%), prominent
Other players: 🚂🟢🟡🟣 Low opacity (30-40%), "ghost" effect
@@ -374,14 +334,12 @@ Benefits:
#### Gameplay Mechanics
**Movement**:
- Answer complement questions to build momentum
- Correct answer → +15 momentum → train speed increases
- Each player has independent momentum/speed
- Trains can pass through each other (no collision)
**Pickup Rules**:
```typescript
When train reaches station (within 5% position):
IF passenger waiting at station:
@@ -393,7 +351,6 @@ When train reaches station (within 5% position):
```
**Delivery Rules**:
```typescript
When train with passenger reaches destination station:
Auto-deliver
@@ -403,13 +360,11 @@ When train with passenger reaches destination station:
```
**Capacity**:
- Each train: 3 passenger cars = max 3 concurrent passengers
- Must deliver before picking up more
- Strategic choice: quick nearby delivery vs. valuable long-distance
**Resource Competition**:
- 6-8 passengers per route
- 4 players competing
- Not enough for everyone to get all passengers
@@ -418,25 +373,21 @@ When train with passenger reaches destination station:
#### Win Conditions (Host Configurable)
**Route-based** (default):
- Play 3 routes (3 minutes)
- Most passengers delivered wins
- Tiebreaker: total points
**Score-based**:
- First to 100 points
- Urgent passengers (20pts) are strategic targets
**Time-based**:
- 5-minute session
- Most deliveries at time limit
### 5.2 Practice/Survival Mode Multiplayer
**Practice Mode**: Linear race track with multiple lanes
- 2-4 horizontal lanes stacked vertically
- Each player in their own lane
- AI opponents fill remaining lanes (optional)
@@ -445,7 +396,6 @@ When train with passenger reaches destination station:
- First to 20 questions wins
**Survival Mode**: Circular track with lap counting
- Players race on shared circular track
- Lap counter instead of finish line
- Infinite laps, timed rounds
@@ -454,7 +404,6 @@ When train with passenger reaches destination station:
### 5.3 Practice Mode: Simultaneous Questions
**Question Flow**:
```
1. Same question appears for all players: "7 + ? = 10"
2. Players race to answer (optional: show "🤔" indicator)
@@ -471,7 +420,6 @@ When train with passenger reaches destination station:
```
**Strategic Tension**:
- Rush to be first (more reward) vs. take time to be accurate
- See opponents' progress in real-time
- Dramatic overtaking moments
@@ -479,10 +427,7 @@ When train with passenger reaches destination station:
### 5.4 AI Opponent Scaling
```typescript
function getAICount(
config: ComplementRaceGameConfig,
humanPlayers: number,
): number {
function getAICount(config: ComplementRaceGameConfig, humanPlayers: number): number {
if (!config.enableAI) return 0;
const totalRacers = humanPlayers + config.aiOpponentCount;
@@ -493,7 +438,6 @@ function getAICount(
```
**AI Behavior in Multiplayer**:
- Optional (host configurable)
- Fill empty lanes in practice/survival modes
- Act as ghost trains in sprint mode
@@ -503,7 +447,6 @@ function getAICount(
### 5.5 Live Updates & Broadcasts
**Event Feed** (shown to all players):
```
• 🟦 Player 1 delivered 👨‍💼 Bob! +10 pts
• 🟢 Player 2 picked up 👩‍🎓 Alice at Hillside
@@ -512,7 +455,6 @@ function getAICount(
```
**Tension Moments** (sprint mode):
```
When 2+ players approach same station:
"🚨 Race for passenger at Riverside!"
@@ -524,7 +466,6 @@ Result:
```
**Scoreboard** (always visible):
```
🏆 LEADERBOARD:
1. 🟣 Player 4: 4 delivered (50 pts)
@@ -540,14 +481,12 @@ Result:
### 6.1 Track Visualization Updates
**Practice/Survival Mode**:
- Stack up to 4 player tracks vertically
- Show player names/avatars
- Color-code each player's lane
- Show AI opponents in separate lanes
**Sprint Mode**:
- Show multiple trains on same track OR
- Picture-in-picture mini views OR
- Leaderboard overlay with positions
@@ -555,7 +494,6 @@ Result:
### 6.2 Settings UI
**Add to GameControls.tsx**:
- Max Players selector (1-4)
- Enable AI toggle
- AI Opponent Count (0-2)
@@ -564,7 +502,6 @@ Result:
### 6.3 Lobby/Waiting Room
**Add GameLobby.tsx phase**:
- Show connected players
- Ready check system
- Host can change settings
@@ -573,7 +510,6 @@ Result:
### 6.4 Results Screen Updates
**Show multiplayer results**:
- Leaderboard with all player scores
- Individual stats per player
- Replay button (returns to lobby)
@@ -584,21 +520,19 @@ Result:
## Phase 7: Registry & Routing ✓
### 7.1 Update Game Registry
**File**: `src/lib/validators/index.ts`
```typescript
import { ComplementRaceValidator } from "./ComplementRaceValidator";
import { ComplementRaceValidator } from './ComplementRaceValidator';
export const GAME_VALIDATORS = {
matching: MatchingGameValidator,
"number-guesser": NumberGuesserValidator,
"complement-race": ComplementRaceValidator, // ADD THIS
'matching': MatchingGameValidator,
'number-guesser': NumberGuesserValidator,
'complement-race': ComplementRaceValidator, // ADD THIS
} as const;
```
### 7.2 Update Game Config
**File**: `src/lib/game-configs.ts`
```typescript
@@ -609,34 +543,26 @@ export type GameConfig =
```
### 7.3 Update GameSelector
**File**: `src/components/GameSelector.tsx`
```typescript
GAMES_CONFIG = {
"complement-race": {
name: "Speed Complement Race",
fullName: "Speed Complement Race 🏁",
'complement-race': {
name: 'Speed Complement Race',
fullName: 'Speed Complement Race 🏁',
maxPlayers: 4, // CHANGE FROM 1
url: "/arcade/complement-race",
chips: [
"🤖 AI Opponents",
"🔥 Speed Challenge",
"🏆 Three Game Modes",
"👥 Multiplayer",
],
difficulty: "Intermediate",
url: '/arcade/complement-race',
chips: ['🤖 AI Opponents', '🔥 Speed Challenge', '🏆 Three Game Modes', '👥 Multiplayer'],
difficulty: 'Intermediate',
available: true,
},
};
}
}
```
### 7.4 Update Routing
**File**: `src/app/arcade/complement-race/page.tsx`
Add room-based routing:
```typescript
// Support both standalone and room-based play
export default function ComplementRacePage({
@@ -667,7 +593,6 @@ export default function ComplementRacePage({
## Phase 8: Testing & Validation ⚠️ PENDING
### 8.1 Unit Tests
- [ ] ComplementRaceValidator logic
- [ ] Question generation
- [ ] Answer validation
@@ -675,14 +600,12 @@ export default function ComplementRacePage({
- [ ] AI position updates
### 8.2 Integration Tests
- [ ] Socket event flow
- [ ] State synchronization
- [ ] Room configuration persistence
- [ ] Multi-player race logic
### 8.3 E2E Tests
- [ ] Single-player mode (backward compatibility)
- [ ] Multiplayer with 2 players
- [ ] Multiplayer with 4 players
@@ -691,7 +614,6 @@ export default function ComplementRacePage({
- [ ] Settings persistence across sessions
### 8.4 Manual Testing Checklist
- [ ] Create room with complement-race
- [ ] Join with multiple clients
- [ ] Change settings (host only)
@@ -808,14 +730,12 @@ export function GhostTrain({ position, color, opacity, name, passengerCount }: G
```
**Visual Design**:
- Local player: Full opacity (100%), vibrant colors, clear
- Other players: 30-40% opacity, subtle blur, labeled with name
- Show passenger count on ghost trains
- No collision detection needed (trains pass through each other)
**Checklist**:
- [ ] Create GhostTrain component
- [ ] Update SteamTrainJourney to render all players
- [ ] Test with 2 players (local + 1 ghost)
@@ -929,7 +849,6 @@ export function Lane({ yOffset, isLocalPlayer, children }: LaneProps) {
```
**Features**:
- Each lane is color-coded per player
- Local player's lane has brighter background
- Progress bars show position clearly
@@ -937,7 +856,6 @@ export function Lane({ yOffset, isLocalPlayer, children }: LaneProps) {
- Smooth position interpolation for animations
**Checklist**:
- [ ] Create Lane component
- [ ] Create Racer component (or update existing)
- [ ] Update LinearTrack to render multiple lanes
@@ -1053,7 +971,6 @@ export function LeaderboardRow({ rank, player, isLocalPlayer }: LeaderboardRowPr
```
**Checklist**:
- [ ] Update GameResults.tsx to show leaderboard
- [ ] Create LeaderboardRow component
- [ ] Add winner announcement
@@ -1199,7 +1116,6 @@ return (
```
**Checklist**:
- [ ] Create GameLobby.tsx component
- [ ] Create PlayerCard component
- [ ] Add setReady to Provider context
@@ -1216,7 +1132,6 @@ return (
**Current State**: AI opponents defined in types but not populated
**Files to Update**:
1. `src/arcade-games/complement-race/Validator.ts` - AI logic
2. Track components (LinearTrack, SteamTrainJourney) - AI rendering
@@ -1341,7 +1256,6 @@ private shouldAIAnswerCorrectly(personality: string): boolean {
**Already handled by 9.1 and 9.2** - Since AI opponents are in `state.players`, they'll render automatically as ghost trains/lanes!
**Checklist**:
- [ ] Implement AI population in validateStartGame
- [ ] Implement updateAIPositions logic
- [ ] Add AI answer timing system
@@ -1395,7 +1309,6 @@ export function EventFeed() {
```
**Checklist**:
- [ ] Create EventFeed component
- [ ] Update Validator to emit events
- [ ] Add event types (claim, deliver, overtake)
@@ -1410,13 +1323,11 @@ export function EventFeed() {
**Total Estimated Time**: 15-20 hours
**Priority Breakdown**:
- 🚨 **HIGH** (8-9 hours): Ghost trains, multi-lane track, results screen
- ⚠️ **MEDIUM** (8-12 hours): Lobby system, AI opponents
-**LOW** (3-4 hours): Event feed
**Completion Criteria**:
- [ ] Can see all players' trains/positions in real-time
- [ ] Multiplayer leaderboard shows all players
- [ ] Lobby shows player list with ready indicators
@@ -1425,7 +1336,6 @@ export function EventFeed() {
- [ ] Zero visual glitches with 4 players
**Once Phase 9 is complete**:
- Multiplayer will be FULLY functional
- Overall implementation: 100% complete
- Ready for Phase 8 (Testing & Validation)
@@ -1435,28 +1345,24 @@ export function EventFeed() {
## Implementation Order
### ✅ Priority 1: Foundation (COMPLETE)
1. ✓ Define ComplementRaceGameConfig
2. ✓ Disable debug logging
3. ✓ Create ComplementRaceValidator skeleton
4. ✓ Register in modular system
### ✅ Priority 2: Core Multiplayer (COMPLETE)
5. ✓ Implement validator methods
6. ✓ Socket server integration
7. ✓ Create RoomComplementRaceProvider (State Adapter Pattern)
8. ✓ Update arcade room store
### ✅ Priority 3: Basic UI Integration (COMPLETE)
9. ✓ Add navigation bar (PageWithNav)
10. ✓ Update settings UI
11. ✓ Config persistence
12. ✓ Registry integration
### 🚨 Priority 4: Multiplayer Visuals (CRITICAL - NEXT)
13. [ ] Ghost trains (Sprint Mode)
14. [ ] Multi-lane track (Practice Mode)
15. [ ] Multiplayer results screen
@@ -1464,7 +1370,6 @@ export function EventFeed() {
17. [ ] AI opponent display
### Priority 5: Testing & Polish (FINAL)
18. [ ] Write tests (unit, integration, E2E)
19. [ ] Manual testing with 2-4 players
20. [ ] Bug fixes
@@ -1476,19 +1381,15 @@ export function EventFeed() {
## Risk Mitigation
### Risk 1: Breaking Existing Single-Player
**Mitigation**: Keep existing Provider, add new RoomProvider, support both paths
### Risk 2: Complex Sprint Mode State Sync
**Mitigation**: Start with Practice mode, add Sprint later, use delta compression
### Risk 3: Performance with 4 Players
**Mitigation**: Optimize rendering, use React.memo, throttle updates, profile early
### Risk 4: AI + Multiplayer Complexity
**Mitigation**: Make AI optional, test with AI disabled first, add AI last
---
@@ -1496,7 +1397,6 @@ export function EventFeed() {
## Reference Games
Use these as architectural reference:
- **Matching Game** (`src/lib/validators/MatchingGameValidator.ts`) - Room config, socket integration
- **Number Guesser** (`src/lib/validators/NumberGuesserValidator.ts`) - Turn-based logic
- **Game Settings Docs** (`.claude/GAME_SETTINGS_PERSISTENCE.md`) - Config patterns
@@ -1506,7 +1406,6 @@ Use these as architectural reference:
## Success Criteria
### ✅ Backend & Infrastructure (COMPLETE)
- [x] Complement Race appears in arcade room game selector
- [x] Can create room with complement-race
- [x] Settings persist across page refreshes
@@ -1516,7 +1415,6 @@ Use these as architectural reference:
- [x] Pre-commit checks pass
### ⚠️ Multiplayer Visuals (IN PROGRESS - Phase 9)
- [ ] **Sprint Mode**: Can see other players' trains (ghost effect)
- [ ] **Practice Mode**: Multi-lane track shows all players
- [ ] **Survival Mode**: Circular track with multiple players
@@ -1526,7 +1424,6 @@ Use these as architectural reference:
- [ ] AI opponents visible in all game modes
### Testing & Polish (PENDING)
- [ ] 2-player multiplayer test (all 3 modes)
- [ ] 4-player multiplayer test (all 3 modes)
- [ ] AI + human players test
@@ -1537,7 +1434,6 @@ Use these as architectural reference:
- [ ] Event feed for competitive tension (optional)
### Current Status: 70% Complete
**What Works**: Backend, state management, config persistence, navigation
**What's Missing**: Multiplayer visualization (ghost trains, multi-lane tracks, lobby UI)
@@ -1548,18 +1444,15 @@ Use these as architectural reference:
**Immediate Priority**: Phase 9 - Multiplayer Visual Features
### Quick Wins (Do These First)
1. **Ghost Trains** (2-3 hours) - Make Sprint mode multiplayer visible
2. **Multi-Lane Track** (3-4 hours) - Make Practice mode multiplayer visible
3. **Results Screen** (1-2 hours) - Show full leaderboard
### After Quick Wins
4. **Visual Lobby** (2-3 hours) - Add ready check system
5. **AI Opponents** (4-6 hours) - Populate and display AI players
### Then Testing
6. Manual testing with 2+ players
7. Bug fixes and polish
8. Unit/integration tests

View File

@@ -22,13 +22,11 @@
### Phase 1: Configuration & Type System ✅ COMPLETE
**Plan Requirements**:
- Define ComplementRaceGameConfig
- Disable debug logging
- Set up type system
**Actual Implementation**:
```typescript
// ✅ CORRECT: Full config interface in types.ts
export interface ComplementRaceConfig {
@@ -62,7 +60,6 @@ export interface ComplementRaceConfig {
### Phase 2: Validator Implementation ✅ COMPLETE
**Plan Requirements**:
- Create ComplementRaceValidator class
- Implement all move validation methods
- Handle scoring, questions, and game state
@@ -70,7 +67,6 @@ export interface ComplementRaceConfig {
**Actual Implementation**:
**✅ All Required Methods Implemented**:
- `validateStartGame` - Initialize multiplayer game
- `validateSubmitAnswer` - Validate answers, update scores
- `validateClaimPassenger` - Sprint mode passenger pickup
@@ -83,7 +79,6 @@ export interface ComplementRaceConfig {
- `validatePlayAgain` - Restart
**✅ Helper Methods**:
- `generateQuestion` - Random question generation
- `calculateAnswerScore` - Scoring with speed/streak bonuses
- `generatePassengers` - Sprint mode passenger spawning
@@ -91,7 +86,6 @@ export interface ComplementRaceConfig {
- `calculateLeaderboard` - Sort players by score
**✅ State Structure** matches plan:
```typescript
interface ComplementRaceState {
config: ComplementRaceConfig
@@ -113,7 +107,6 @@ interface ComplementRaceState {
### Phase 3: Socket Server Integration ✅ COMPLETE
**Plan Requirements**:
- Register in validators.ts
- Socket event handling
- Real-time synchronization
@@ -121,27 +114,25 @@ interface ComplementRaceState {
**Actual Implementation**:
**Registered in validators.ts**:
```typescript
import { complementRaceValidator } from "@/arcade-games/complement-race/Validator";
import { complementRaceValidator } from '@/arcade-games/complement-race/Validator'
export const VALIDATORS = {
matching: matchingGameValidator,
"number-guesser": numberGuesserValidator,
"complement-race": complementRaceValidator, // ✅ CORRECT
};
'number-guesser': numberGuesserValidator,
'complement-race': complementRaceValidator, // ✅ CORRECT
}
```
**Registered in game-registry.ts**:
```typescript
import { complementRaceGame } from "@/arcade-games/complement-race";
import { complementRaceGame } from '@/arcade-games/complement-race'
const GAME_REGISTRY = {
matching: matchingGame,
"number-guesser": numberGuesserGame,
"complement-race": complementRaceGame, // ✅ CORRECT
};
'number-guesser': numberGuesserGame,
'complement-race': complementRaceGame, // ✅ CORRECT
}
```
**Uses standard useArcadeSession pattern** - Socket integration automatic via SDK
@@ -157,20 +148,16 @@ const GAME_REGISTRY = {
**Actual Implementation**: **State Adapter Pattern** (Better Solution!)
Instead of creating a separate RoomProvider, we:
1. ✅ Used standard **useArcadeSession** pattern in Provider.tsx
2. ✅ Created **state transformation layer** to bridge multiplayer ↔ single-player UI
3. ✅ Preserved ALL existing UI components without changes
4. ✅ Config merging from roomData works correctly
**Key Innovation**:
```typescript
// Transform multiplayer state to look like single-player state
const compatibleState = useMemo((): CompatibleGameState => {
const localPlayer = localPlayerId
? multiplayerState.players[localPlayerId]
: null;
const localPlayer = localPlayerId ? multiplayerState.players[localPlayerId] : null
return {
// Extract local player's data
@@ -178,12 +165,11 @@ const compatibleState = useMemo((): CompatibleGameState => {
score: localPlayer?.score || 0,
streak: localPlayer?.streak || 0,
// ... etc
};
}, [multiplayerState, localPlayerId]);
}
}, [multiplayerState, localPlayerId])
```
This is **better than the plan** because:
- No code duplication
- Reuses existing components
- Clean separation of concerns
@@ -198,7 +184,6 @@ This is **better than the plan** because:
**Plan Requirements** vs **Implementation**:
#### 5.1 Sprint Mode: Passenger Rush ✅ IMPLEMENTED
- ✅ Shared passenger pool (all players see same passengers)
- ✅ First-come-first-served claiming (`claimedBy` field)
- ✅ Delivery points (10 regular, 20 urgent)
@@ -209,7 +194,6 @@ This is **better than the plan** because:
**Status**: **Server logic complete, visual features missing**
#### 5.2 Practice Mode: Simultaneous Questions ⚠️ NEEDS WORK
- ✅ Question generation per player works
- ✅ Answer validation works
- ✅ Position tracking works
@@ -220,7 +204,6 @@ This is **better than the plan** because:
**Status**: **Backend works, frontend needs multiplayer UI**
#### 5.3 Survival Mode ⚠️ NEEDS WORK
- ✅ Position/lap tracking logic exists
-**MISSING**: Circular track with multiple players
-**MISSING**: Lap counter display
@@ -229,7 +212,6 @@ This is **better than the plan** because:
**Status**: **Basic structure, needs multiplayer visuals**
#### 5.4 AI Opponent Scaling ❌ NOT IMPLEMENTED
- ❌ AI opponents defined in types but not populated
- ❌ No AI update logic in validator
-`aiOpponents` array stays empty
@@ -237,7 +219,6 @@ This is **better than the plan** because:
**Status**: **Needs implementation**
#### 5.5 Live Updates & Broadcasts ❌ NOT IMPLEMENTED
- ❌ No event feed component
- ❌ No "race for passenger" alerts
- ❌ No live leaderboard overlay
@@ -254,7 +235,6 @@ This is **better than the plan** because:
**Plan Requirements** vs **Implementation**:
#### 6.1 Track Visualization ❌ NOT UPDATED
- ❌ Practice: No multi-lane track (still shows single player)
- ❌ Sprint: No ghost trains (only local train visible)
- ❌ Survival: No multi-player circular track
@@ -262,13 +242,11 @@ This is **better than the plan** because:
**Current State**: UI still shows **single-player view only**
#### 6.2 Settings UI ✅ COMPLETE
- ✅ GameControls.tsx has all settings
- ✅ Max players, AI settings, game mode all configurable
- ✅ Settings persist via arcade room store
#### 6.3 Lobby/Waiting Room ⚠️ PARTIAL
- ⚠️ Uses "controls" phase as lobby (functional but not ideal)
- ❌ No visual "ready check" system
- ❌ No player list with ready indicators
@@ -277,7 +255,6 @@ This is **better than the plan** because:
**Should Add**: Proper lobby phase with visual ready checks
#### 6.4 Results Screen ⚠️ PARTIAL
- ✅ GameResults.tsx exists
- ❌ No multiplayer leaderboard (still shows single-player stats)
- ❌ No per-player breakdown
@@ -290,13 +267,11 @@ This is **better than the plan** because:
### Phase 7: Registry & Routing ✅ COMPLETE
**Plan Requirements**:
- Update game registry
- Update validators
- Update routing
**Actual Implementation**:
- ✅ Registered in validators.ts
- ✅ Registered in game-registry.ts
- ✅ Registered in game-configs.ts
@@ -311,7 +286,6 @@ This is **better than the plan** because:
### Phase 8: Testing & Validation ❌ NOT DONE
All testing checkboxes remain unchecked:
- [ ] Unit tests
- [ ] Integration tests
- [ ] E2E tests
@@ -433,7 +407,6 @@ From migration plan's "Success Criteria":
### Immediate Next Steps (To Complete Multiplayer)
1. **Implement Ghost Trains** (2-3 hours)
```typescript
// In SteamTrainJourney.tsx
{Object.entries(state.players).map(([playerId, player]) => {
@@ -451,7 +424,6 @@ From migration plan's "Success Criteria":
```
2. **Add Multi-Lane Track** (3-4 hours)
```typescript
// In LinearTrack.tsx
const lanes = Object.values(state.players)
@@ -496,14 +468,12 @@ From migration plan's "Success Criteria":
### Overall Grade: **B (70%)**
**Strengths**:
-**Excellent architecture** - State adapter is ingenious
-**Complete backend logic** - Validator fully functional
-**Proper integration** - Follows all patterns correctly
-**Type safety** - Zero TypeScript errors
**Weaknesses**:
-**Missing multiplayer visuals** - Can't see other players
-**No AI opponents** - Can't test solo
-**Minimal lobby** - Auto-starts instead of ready check
@@ -519,14 +489,12 @@ From migration plan's "Success Criteria":
### What Would Make This Complete?
**Minimum Viable Multiplayer** (8-10 hours of work):
1. Ghost trains in sprint mode
2. Multi-lane tracks in practice mode
3. Multiplayer leaderboard in results
4. Lobby with ready checks
**Full Polish** (20-25 hours total):
- Above + AI opponents
- Above + event feed
- Above + comprehensive testing

View File

@@ -11,14 +11,12 @@
### ✅ Phase 1: Foundation & Architecture (COMPLETE)
**1. Comprehensive Migration Plan**
- File: `.claude/COMPLEMENT_RACE_MIGRATION_PLAN.md`
- Detailed multiplayer game design with ghost train visualization
- Shared universe passenger competition mechanics
- Complete 8-phase implementation roadmap
**2. Type System** (`src/arcade-games/complement-race/types.ts`)
- `ComplementRaceConfig` - Full game configuration with all settings
- `ComplementRaceState` - Multiplayer game state management
- `ComplementRaceMove` - Player action types
@@ -26,7 +24,6 @@
- All types fully documented and exported
**3. Validator** (`src/arcade-games/complement-race/Validator.ts`) - **~700 lines**
- ✅ Question generation (friends of 5, 10, mixed)
- ✅ Answer validation with scoring
- ✅ Player progress tracking
@@ -38,7 +35,6 @@
- Fully implements `GameValidator<ComplementRaceState, ComplementRaceMove>`
**4. Game Definition** (`src/arcade-games/complement-race/index.tsx`)
- Manifest with game metadata
- Default configuration
- Config validation function
@@ -47,7 +43,6 @@
- Properly typed with generics
**5. Registry Integration**
- ✅ Registered in `src/lib/arcade/validators.ts`
- ✅ Registered in `src/lib/arcade/game-registry.ts`
- ✅ Added types to `src/lib/arcade/validation/types.ts`
@@ -55,7 +50,6 @@
- ✅ Added types to `src/lib/arcade/game-configs.ts`
**6. Configuration System**
-`ComplementRaceGameConfig` defined with all settings:
- Game style (practice, sprint, survival)
- Question settings (mode, display type)
@@ -68,7 +62,6 @@
- ✅ Room-based config persistence supported
**7. Code Quality**
- ✅ Debug logging disabled (`DEBUG_PASSENGER_BOARDING = false`)
- ✅ New modular code compiles (only 1 minor type warning)
- ✅ Backward compatible Station type (icon + emoji fields)
@@ -81,20 +74,17 @@
### Core Mechanics
**Shared Universe**:
- ONE track with ONE set of passengers
- Real competition for limited resources
- First to station claims passenger
- Ghost train visualization (opponents at 30-40% opacity)
**Player Capacity**:
- 1-4 players per game
- 3 passenger cars per train
- Strategic delivery choices
**Win Conditions** (Host Configurable):
1. **Route-based**: Complete N routes, highest score wins
2. **Score-based**: First to target score
3. **Time-based**: Most deliveries in time limit
@@ -102,20 +92,17 @@
### Game Modes
**Practice Mode**: Linear race
- First to 20 questions wins
- Optional AI opponents
- Simultaneous question answering
**Sprint Mode**: Train journey with passengers
- 60-second routes
- Passenger pickup/delivery competition
- Momentum system
- Time-of-day cycles
**Survival Mode**: Infinite laps
- Circular track
- Lap counting
- Endurance challenge
@@ -165,34 +152,30 @@ src/lib/arcade/
```typescript
// Create: src/arcade-games/complement-race/__tests__/Validator.test.ts
import { complementRaceValidator } from "../Validator";
import { DEFAULT_COMPLEMENT_RACE_CONFIG } from "@/lib/arcade/game-configs";
import { complementRaceValidator } from '../Validator'
import { DEFAULT_COMPLEMENT_RACE_CONFIG } from '@/lib/arcade/game-configs'
test("generates initial state", () => {
const state = complementRaceValidator.getInitialState(
DEFAULT_COMPLEMENT_RACE_CONFIG,
);
expect(state.gamePhase).toBe("setup");
expect(state.stations).toHaveLength(6);
});
test('generates initial state', () => {
const state = complementRaceValidator.getInitialState(DEFAULT_COMPLEMENT_RACE_CONFIG)
expect(state.gamePhase).toBe('setup')
expect(state.stations).toHaveLength(6)
})
test("validates starting game", () => {
const state = complementRaceValidator.getInitialState(
DEFAULT_COMPLEMENT_RACE_CONFIG,
);
test('validates starting game', () => {
const state = complementRaceValidator.getInitialState(DEFAULT_COMPLEMENT_RACE_CONFIG)
const result = complementRaceValidator.validateMove(state, {
type: "START_GAME",
playerId: "p1",
userId: "u1",
type: 'START_GAME',
playerId: 'p1',
userId: 'u1',
timestamp: Date.now(),
data: {
activePlayers: ["p1", "p2"],
playerMetadata: { p1: { name: "Alice" }, p2: { name: "Bob" } },
},
});
expect(result.valid).toBe(true);
expect(result.newState?.activePlayers).toHaveLength(2);
});
activePlayers: ['p1', 'p2'],
playerMetadata: { p1: { name: 'Alice' }, p2: { name: 'Bob' } }
}
})
expect(result.valid).toBe(true)
expect(result.newState?.activePlayers).toHaveLength(2)
})
```
### 2. Game Appears in Selector
@@ -226,12 +209,10 @@ npm run type-check
## ✅ What's Been Implemented (Update)
### Provider Component
**Status**: ✅ Complete
**Location**: `src/arcade-games/complement-race/Provider.tsx`
**Implemented**:
- ✅ Socket connection via useArcadeSession
- ✅ Real-time state synchronization
- ✅ Config loading from room (with persistence)
@@ -240,12 +221,10 @@ npm run type-check
- ✅ Optimistic update handling
### Game UI Component
**Status**: ✅ MVP Complete
**Location**: `src/arcade-games/complement-race/Game.tsx`
**Implemented**:
- ✅ Setup phase with game settings display
- ✅ Lobby/countdown phase UI
- ✅ Playing phase with:
@@ -260,7 +239,6 @@ npm run type-check
### What's Still Pending
**Multiplayer-Specific Features** (can be added later):
- Ghost train visualization (opacity-based rendering)
- Shared passenger board (sprint mode)
- Advanced race track visualization
@@ -274,14 +252,12 @@ npm run type-check
### Immediate (Can Test Multiplayer)
**1. Create RoomComplementRaceProvider** (~2-3 hours)
- Connect to socket
- Load room config
- Sync state with server
- Handle moves
**2. Create Basic Multiplayer UI** (~3-4 hours)
- Show all player positions
- Render ghost trains
- Display shared passenger board
@@ -290,19 +266,16 @@ npm run type-check
### Polish (Make it Great)
**3. Sprint Mode Multiplayer** (~4-6 hours)
- Multiple trains on same track
- Passenger competition visualization
- Route celebration for all players
**4. Practice/Survival Modes** (~2-3 hours)
- Multi-lane racing
- Lap tracking (survival)
- Finish line detection
**5. Testing & Bug Fixes** (~2-3 hours)
- End-to-end multiplayer testing
- Handle edge cases
- Performance optimization
@@ -340,17 +313,14 @@ npm run type-check
## 🔗 Important Files to Reference
**For Provider Implementation**:
- `src/arcade-games/number-guesser/Provider.tsx` - Socket integration pattern
- `src/arcade-games/matching/Provider.tsx` - Room config loading
**For UI Implementation**:
- `src/app/arcade/complement-race/components/` - Existing UI components
- `src/arcade-games/number-guesser/components/` - Multiplayer UI patterns
**For Testing**:
- `src/arcade-games/number-guesser/__tests__/` - Validator test patterns
- `.claude/GAME_SETTINGS_PERSISTENCE.md` - Config testing guide
@@ -380,7 +350,6 @@ npm run type-check
### What Was Wrong
I initially created a **simple quiz UI** (`Game.tsx`) from scratch, throwing away ALL the existing beautiful components:
- ❌ No RailroadTrackPath
- ❌ No SteamTrainJourney
- ❌ No PassengerCard
@@ -396,14 +365,13 @@ The user rightfully said: **"what the fuck is this game?"**
**Updated** `index.tsx` to use existing `ComplementRaceGame` from `src/app/arcade/complement-race/components/`
**Added** `dispatch` compatibility layer to Provider to bridge action creators with existing UI expectations
**Preserved** ALL existing beautiful UI components:
- Train animations
- Track visualization
- Passenger mechanics ✅
- Route celebrations
- HUD with pressure gauge
- Adaptive difficulty
- AI opponents ✅
- Train animations ✅
- Track visualization ✅
- Passenger mechanics
- Route celebrations ✅
- HUD with pressure gauge
- Adaptive difficulty
- AI opponents
### What Works Now

View File

@@ -5,14 +5,12 @@
The existing single-player UI components were deeply coupled to a specific state shape that differed from the new multiplayer state structure:
**Old Single-Player State**:
- `currentQuestion` - single question object at root level
- `correctAnswers`, `streak`, `score` - at root level
- `gamePhase: 'intro' | 'controls' | 'countdown' | 'playing' | 'results'`
- Config fields at root: `mode`, `style`, `complementDisplay`
**New Multiplayer State**:
- `currentQuestions: Record<playerId, question>` - per player
- `players: Record<playerId, PlayerState>` - stats nested in player objects
- `gamePhase: 'setup' | 'lobby' | 'countdown' | 'playing' | 'results'`
@@ -35,7 +33,6 @@ Defined an interface that matches the old single-player `GameState` shape, allow
#### 2. Local UI State
Uses `useState` to track local UI state that doesn't need server synchronization:
- `currentInput` - what user is typing
- `previousQuestion` - for animations
- `isPaused` - local pause state
@@ -50,14 +47,12 @@ Transforms multiplayer state into compatible single-player shape:
```typescript
const compatibleState = useMemo((): CompatibleGameState => {
const localPlayer = localPlayerId
? multiplayerState.players[localPlayerId]
: null;
const localPlayer = localPlayerId ? multiplayerState.players[localPlayerId] : null
// Map gamePhase: setup/lobby -> controls
let gamePhase = multiplayerState.gamePhase;
if (gamePhase === "setup" || gamePhase === "lobby") {
gamePhase = "controls";
let gamePhase = multiplayerState.gamePhase
if (gamePhase === 'setup' || gamePhase === 'lobby') {
gamePhase = 'controls'
}
return {
@@ -75,7 +70,7 @@ const compatibleState = useMemo((): CompatibleGameState => {
streak: localPlayer?.streak || 0,
// Map AI opponents to old aiRacers format
aiRacers: multiplayerState.aiOpponents.map((ai) => ({
aiRacers: multiplayerState.aiOpponents.map(ai => ({
id: ai.id,
name: ai.name,
position: ai.position,
@@ -86,8 +81,8 @@ const compatibleState = useMemo((): CompatibleGameState => {
currentInput: localUIState.currentInput,
adaptiveFeedback: localUIState.adaptiveFeedback,
// ... etc
};
}, [multiplayerState, localPlayerId, localUIState]);
}
}, [multiplayerState, localPlayerId, localUIState])
```
#### 4. Compatibility Dispatch
@@ -95,29 +90,26 @@ const compatibleState = useMemo((): CompatibleGameState => {
Maps old reducer action types to new action creators:
```typescript
const dispatch = useCallback(
(action: { type: string; [key: string]: any }) => {
switch (action.type) {
case "START_COUNTDOWN":
case "BEGIN_GAME":
startGame();
break;
const dispatch = useCallback((action: { type: string; [key: string]: any }) => {
switch (action.type) {
case 'START_COUNTDOWN':
case 'BEGIN_GAME':
startGame()
break
case "SUBMIT_ANSWER":
const responseTime = Date.now() - multiplayerState.questionStartTime;
submitAnswer(action.answer, responseTime);
break;
case 'SUBMIT_ANSWER':
const responseTime = Date.now() - multiplayerState.questionStartTime
submitAnswer(action.answer, responseTime)
break
// Local UI state actions
case "UPDATE_INPUT":
setLocalUIState((prev) => ({ ...prev, currentInput: action.input }));
break;
// Local UI state actions
case 'UPDATE_INPUT':
setLocalUIState(prev => ({ ...prev, currentInput: action.input }))
break
// ... etc
}
},
[startGame, submitAnswer, multiplayerState.questionStartTime],
);
// ... etc
}
}, [startGame, submitAnswer, multiplayerState.questionStartTime])
```
## Benefits
@@ -140,13 +132,11 @@ const dispatch = useCallback(
## Testing
### Type Checking
- ✅ No TypeScript errors in new code
- ✅ All component files compile successfully
- ✅ Only pre-existing errors remain (known @soroban/abacus-react issue)
### Format & Lint
- ✅ Code formatted with Biome
- ✅ No new lint warnings
- ✅ All style guidelines followed

View File

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

View File

@@ -1,192 +0,0 @@
# ConfigPanel Refactoring - Completion Report
## Executive Summary
Successfully refactored the monolithic 2550-line ConfigPanel.tsx into a modular, maintainable architecture. **Final reduction: 95.9% (2550 lines → 105 lines)**.
## Phases Completed
### ✅ Phase 1: Helper Components
- Created `config-panel/` subdirectory
- Extracted `utils.tsx` (66 lines) - scaffolding summary helper
- Extracted `SubOption.tsx` (79 lines) - nested toggle component
- Extracted `ToggleOption.tsx` (112 lines) - main toggle with description
- **Commit:** `d1f8ba66`
### ✅ Phase 2: Shared Sections
- Extracted `StudentNameInput.tsx` (32 lines) - text input
- Extracted `DigitRangeSection.tsx` (173 lines) - double-thumb range slider
- Extracted `OperatorSection.tsx` (129 lines) - operator selection buttons
- Extracted `ProgressiveDifficultyToggle.tsx` (91 lines) - interpolate toggle
- **Commits:** `d7d97023`, `60875bfc`
### ✅ Phase 3: Smart Mode Controls
- Extracted `SmartModeControls.tsx` (1412 lines) - entire Smart Mode section
- Difficulty preset dropdown
- Make easier/harder buttons
- Overall difficulty slider
- 2D difficulty space visualizer with interactive SVG
- Scaffolding summary tooltips
- Removed useState dependencies from ConfigPanel
- **Commit:** `e870ef20`
### ✅ Phase 4: Manual Mode Controls
- Extracted `ManualModeControls.tsx` (342 lines) - entire Manual Mode section
- Display options toggles (carry boxes, answer boxes, place value colors, etc.)
- Check All / Uncheck All buttons
- Live preview panel (DisplayOptionsPreview)
- Regrouping frequency double-thumb slider
- Conditional borrowing notation/hints toggles
- Fixed parsing error (extra closing paren)
- **Commit:** `e12651f6`
### ✅ Phase 5: Final Cleanup
- Removed all unused helper functions
- Removed unused state variables
- Removed debugging console.log statements
- Added missing `defaultAdditionConfig` import
- Added missing `Slider` import to ManualModeControls
- Cleaned up backup files and temp scripts
- **Commit:** `c33fa173`
## Architecture After Refactoring
### Final ConfigPanel.tsx (105 lines)
```
ConfigPanel
├── Imports (11 lines)
│ ├── Panda CSS (stack pattern)
│ ├── Types (WorksheetFormState)
│ ├── Config (defaultAdditionConfig)
│ └── Child Components (6 imports)
├── Mode Switch Handler (50 lines)
│ ├── Smart mode: preserve displayRules, set profile
│ └── Manual mode: convert displayRules to boolean flags
└── JSX Render (35 lines)
├── StudentNameInput
├── DigitRangeSection
├── OperatorSection
├── ModeSelector
├── ProgressiveDifficultyToggle
├── SmartModeControls (conditional)
└── ManualModeControls (conditional)
```
### Component Directory Structure
```
components/
├── ConfigPanel.tsx (105 lines) - main orchestrator
├── ModeSelector.tsx - existing component
├── DisplayOptionsPreview.tsx - existing component
└── config-panel/
├── utils.tsx (66 lines)
├── SubOption.tsx (79 lines)
├── ToggleOption.tsx (112 lines)
├── StudentNameInput.tsx (32 lines)
├── DigitRangeSection.tsx (173 lines)
├── OperatorSection.tsx (129 lines)
├── ProgressiveDifficultyToggle.tsx (91 lines)
├── SmartModeControls.tsx (1412 lines)
└── ManualModeControls.tsx (342 lines)
```
### Total Lines: 2541 lines across 10 modular files
- **Before:** 2550 lines in 1 monolithic file
- **After:** 105 lines orchestrator + 2436 lines across 9 focused components
- **Net change:** -9 lines total (improved organization without code bloat)
## Benefits Achieved
### ✅ Maintainability
- Each component has a single, clear responsibility
- Changes to Smart Mode don't affect Manual Mode and vice versa
- Easy to locate and modify specific UI sections
### ✅ Testability
- Can unit test individual components in isolation
- Mock data is simpler (only relevant props per component)
- Component boundaries align with feature boundaries
### ✅ Readability
- ConfigPanel.tsx is now a clear high-level overview
- Component names are self-documenting
- Related code is co-located in dedicated files
### ✅ Reusability
- ToggleOption and SubOption can be used in other forms
- StudentNameInput pattern can be extended to other text inputs
- DigitRangeSection slider logic can be adapted for other ranges
### ✅ Zero Functionality Change
- All 5 phases maintained identical UI behavior
- No regressions introduced
- All commits tested incrementally
## Metrics
| Metric | Before | After | Change |
| ----------------------------- | ---------- | ---------- | ---------- |
| ConfigPanel.tsx size | 2550 lines | 105 lines | **-95.9%** |
| Number of files | 1 | 10 | +900% |
| Average file size | 2550 lines | 254 lines | -90.0% |
| Largest component | 2550 lines | 1412 lines | -44.6% |
| Import statements | 20+ | 11 | -45% |
| useState hooks in ConfigPanel | 3 | 0 | -100% |
## Lessons Learned
### ✅ What Worked Well
1. **Incremental approach** - 5 small phases instead of 1 big bang
2. **Commit after each phase** - easy to roll back if needed
3. **Extract before delete** - created new files first, then removed from original
4. **Testing at each step** - caught issues early (Slider import, parsing error)
### ⚠️ Issues Encountered
1. **Missing Slider import** (Phase 2) - removed too early, had to add back temporarily
2. **Parsing error in ManualModeControls** (Phase 4) - extra closing paren from extraction
3. **Missing Slider import again** (Phase 5) - forgot to add to ManualModeControls
### 💡 Best Practices Established
1. **Always check imports** - verify each extracted component has all necessary imports
2. **Format after extraction** - biome catches syntax errors immediately
3. **Search for usage** - grep for function names before removing
4. **Keep backup files** - ConfigPanel.tsx.bak useful for comparison (deleted after completion)
## Next Steps (Optional Future Improvements)
### Consider for Future Refactoring:
1. **Extract layout helpers** - `getDefaultColsForProblemsPerPage` and `calculateDerivedState` could go in a `layoutUtils.ts` file if needed again
2. **Shared prop types** - Create `config-panel/types.ts` for common interfaces
3. **Storybook stories** - Add stories for each extracted component
4. **Unit tests** - Add tests for ToggleOption, SubOption, mode switching logic
### Current State: Production Ready ✅
- All phases complete
- All commits clean
- No known issues
- Zero functionality change
- 95.9% size reduction achieved
---
**Refactoring completed:** 2025-11-08
**Total commits:** 5 phases across 5 commits
**Final commit:** `c33fa173`

View File

@@ -1,333 +0,0 @@
# ConfigPanel.tsx Refactoring Plan
**Status**: Ready to begin
**Safe restore point**: `ab3e5a20` - commit before refactoring starts
## Current State Analysis
**File**: `src/app/create/worksheets/addition/components/ConfigPanel.tsx`
**Total lines**: 2550
**Exports**: 1 main component (`ConfigPanel`) + 3 helper components
### Structure Breakdown
#### Helper Components/Functions (Lines 1-284)
1. **`getScaffoldingSummary()`** (lines 44-99) - Generates human-readable scaffolding summary
2. **`SubOption`** component (lines 112-175) - Reusable nested toggle component
3. **`ToggleOption`** component (lines 185-283) - Reusable toggle option with description
#### Main Component (Lines 285-2550)
**`ConfigPanel`** - Massive 2265-line component with:
- Local state management (lines 287-294)
- Helper functions (lines 296-445)
- Main render with 6 sections:
1. **Student Name** (lines 450-471)
2. **Digit Range** section (lines 474-641) - Shared
3. **Operator Selection** section (lines 643-760) - Shared
4. **Progressive Difficulty** toggle (lines 766-837) - Shared
5. **Smart Mode sections** (lines 840-2222):
- Difficulty controls with preset buttons
- 2D visualization plot
- Scaffolding/regrouping sliders
6. **Manual Mode sections** (lines 2225-2548):
- Display options toggles
- Regrouping frequency sliders
### Key Observations
1. **Mode-based conditional rendering**: Smart mode (lines 840-2222) vs Manual mode (lines 2225-2548)
2. **Shared sections**: Student name, digit range, operator selection, progressive difficulty toggle
3. **Complex state management**: Lots of derived state calculations and mode-specific logic
4. **Heavy dependencies**: Uses many difficulty profile functions and constants
5. **Visualization code**: 2D difficulty plot only in smart mode (lines ~1000-2000)
## Proposed Refactoring
### Goals
1. Break monolithic component into focused, testable modules
2. Improve maintainability and readability
3. Preserve all functionality
4. Enable easier future additions
### Extraction Strategy
#### Phase 1: Extract Helper Components to Separate Files
**File**: `src/app/create/worksheets/addition/components/config-panel/ToggleOption.tsx`
- Extract `ToggleOption` component (lines 185-283)
- Extract related types (`ToggleOptionProps`)
**File**: `src/app/create/worksheets/addition/components/config-panel/SubOption.tsx`
- Extract `SubOption` component (lines 112-175)
- Extract related types (`SubOptionProps`)
**File**: `src/app/create/worksheets/addition/components/config-panel/utils.ts`
- Extract `getScaffoldingSummary()` function (lines 44-99)
- Extract other pure helper functions
#### Phase 2: Extract Shared Sections
**File**: `src/app/create/worksheets/addition/components/config-panel/StudentNameInput.tsx`
- Lines 450-471
- Simple controlled input component
**File**: `src/app/create/worksheets/addition/components/config-panel/DigitRangeSection.tsx`
- Lines 474-641
- Digit range min/max selection UI
- Props: `digitRange`, `onChange`
**File**: `src/app/create/worksheets/addition/components/config-panel/OperatorSection.tsx`
- Lines 643-760
- Operator selection (addition/subtraction/mixed)
- Props: `operator`, `onChange`
**File**: `src/app/create/worksheets/addition/components/config-panel/ProgressiveDifficultyToggle.tsx`
- Lines 766-837
- Simple toggle for interpolate setting
- Props: `interpolate`, `onChange`
#### Phase 3: Extract Smart Mode Section
**File**: `src/app/create/worksheets/addition/components/config-panel/SmartModeControls.tsx`
- Lines 840-2222 (~1382 lines)
- All smart mode difficulty controls
- This is still large and should be further broken down:
**Sub-components**:
1. **`DifficultyPresetButtons.tsx`** - Preset difficulty buttons (beginner, early learner, etc.)
2. **`DifficultyVisualization.tsx`** - 2D difficulty plot with hover interactions
3. **`RegroupingSlider.tsx`** - Regrouping frequency slider with level indicator
4. **`ScaffoldingSlider.tsx`** - Scaffolding level slider with summary tooltip
Props for `SmartModeControls`:
```typescript
interface SmartModeControlsProps {
formState: WorksheetFormState;
onChange: (updates: Partial<WorksheetFormState>) => void;
}
```
#### Phase 4: Extract Manual Mode Section
**File**: `src/app/create/worksheets/addition/components/config-panel/ManualModeControls.tsx`
- Lines 2225-2548 (~323 lines)
- All manual mode display and regrouping controls
- Further breakdown:
**Sub-components**:
1. **`DisplayOptionsSection.tsx`** - All display option toggles (lines 2228-2417)
2. **`RegroupingFrequencySection.tsx`** - Manual regrouping sliders (lines 2420-2548)
Props for `ManualModeControls`:
```typescript
interface ManualModeControlsProps {
formState: WorksheetFormState;
onChange: (updates: Partial<WorksheetFormState>) => void;
}
```
#### Phase 5: Simplified Main ConfigPanel
**File**: `src/app/create/worksheets/addition/components/ConfigPanel.tsx` (refactored)
- Orchestrates all sub-components
- Minimal local state (only UI state like `showDebugPlot`, `hoverPoint`)
- Clean conditional rendering for mode-specific sections
**Approximate structure** (~150 lines):
```typescript
export function ConfigPanel({ formState, onChange }: ConfigPanelProps) {
const [showDebugPlot, setShowDebugPlot] = useState(false)
const [hoverPoint, setHoverPoint] = useState<{ x: number; y: number } | null>(null)
const [hoverPreview, setHoverPreview] = useState<...>(null)
// Mode change handler
const handleModeChange = (newMode: 'smart' | 'manual') => { ... }
return (
<div data-component="config-panel" className={stack({ gap: '3' })}>
<StudentNameInput value={formState.name} onChange={(name) => onChange({ name })} />
<DigitRangeSection
digitRange={formState.digitRange}
onChange={(digitRange) => onChange({ digitRange })}
/>
<OperatorSection
operator={formState.operator}
onChange={(operator) => onChange({ operator })}
/>
<ModeSelector currentMode={formState.mode ?? 'smart'} onChange={handleModeChange} />
<ProgressiveDifficultyToggle
interpolate={formState.interpolate}
onChange={(interpolate) => onChange({ interpolate })}
/>
{(!formState.mode || formState.mode === 'smart') && (
<SmartModeControls formState={formState} onChange={onChange} />
)}
{formState.mode === 'manual' && (
<ManualModeControls formState={formState} onChange={onChange} />
)}
</div>
)
}
```
## File Structure After Refactoring
```
src/app/create/worksheets/addition/components/
├── ConfigPanel.tsx (~150 lines - orchestrator)
├── ModeSelector.tsx (existing, already extracted)
├── DisplayOptionsPreview.tsx (existing, already extracted)
└── config-panel/
├── utils.ts (pure helper functions)
├── ToggleOption.tsx (reusable toggle UI)
├── SubOption.tsx (reusable nested toggle UI)
├── StudentNameInput.tsx (simple input)
├── DigitRangeSection.tsx (~170 lines)
├── OperatorSection.tsx (~120 lines)
├── ProgressiveDifficultyToggle.tsx (~70 lines)
├── SmartModeControls.tsx (~300 lines - orchestrator)
│ ├── DifficultyPresetButtons.tsx (~150 lines)
│ ├── DifficultyVisualization.tsx (~600 lines)
│ ├── RegroupingSlider.tsx (~300 lines)
│ └── ScaffoldingSlider.tsx (~300 lines)
└── ManualModeControls.tsx (~150 lines - orchestrator)
├── DisplayOptionsSection.tsx (~200 lines)
└── RegroupingFrequencySection.tsx (~130 lines)
```
## Benefits
1. **Testability**: Each component can be tested in isolation
2. **Readability**: Files are 70-600 lines instead of 2550
3. **Maintainability**: Changes to smart mode don't risk breaking manual mode
4. **Reusability**: Toggle components, sliders can be used elsewhere
5. **Performance**: Potential for React.memo optimization on stable sections
6. **Collaboration**: Multiple developers can work on different sections
## Migration Strategy
**Approach**: Incremental extraction with zero functionality change
1. **Create directory structure** - `config-panel/` subdirectory
2. **Extract helpers first** - utils.ts, ToggleOption, SubOption (low risk)
3. **Extract shared sections** - Student name, digit range, operator, progressive difficulty
4. **Test thoroughly** - Verify UI works identically
5. **Extract mode-specific sections** - Smart mode, then manual mode
6. **Final cleanup** - Simplified main ConfigPanel.tsx
7. **Run pre-commit checks** - Ensure types, format, lint all pass
## Risks and Mitigations
**Risk**: Breaking existing functionality during refactor
- **Mitigation**: Extract one component at a time, test after each step
**Risk**: Props drilling becomes excessive
- **Mitigation**: Keep `formState` and `onChange` at parent level, pass only what's needed
**Risk**: Import path updates needed elsewhere
- **Mitigation**: Main `ConfigPanel` export stays in same location, no external breakage
**Risk**: TypeScript errors from circular dependencies
- **Mitigation**: Keep types in separate files, import only what's needed
## Estimated Effort
- **Phase 1** (Helpers): ~30 minutes
- **Phase 2** (Shared sections): ~1 hour
- **Phase 3** (Smart mode): ~2 hours
- **Phase 4** (Manual mode): ~1 hour
- **Phase 5** (Main refactor): ~30 minutes
- **Testing & refinement**: ~1 hour
**Total**: ~6 hours
## Execution Checklist
### Phase 1: Extract Helper Components ✅ NOT STARTED
- [ ] Create `config-panel/` directory
- [ ] Extract `utils.ts` with `getScaffoldingSummary()`
- [ ] Extract `ToggleOption.tsx`
- [ ] Extract `SubOption.tsx`
- [ ] Update imports in ConfigPanel.tsx
- [ ] Run `npm run pre-commit`
- [ ] Manual test: Verify UI unchanged
- [ ] Commit: "refactor(worksheets): extract ConfigPanel helper components"
### Phase 2: Extract Shared Sections ✅ NOT STARTED
- [ ] Extract `StudentNameInput.tsx`
- [ ] Extract `DigitRangeSection.tsx`
- [ ] Extract `OperatorSection.tsx`
- [ ] Extract `ProgressiveDifficultyToggle.tsx`
- [ ] Update ConfigPanel.tsx to use new components
- [ ] Run `npm run pre-commit`
- [ ] Manual test: Verify all sections work
- [ ] Commit: "refactor(worksheets): extract ConfigPanel shared sections"
### Phase 3: Extract Smart Mode Section ✅ NOT STARTED
- [ ] Extract `SmartModeControls.tsx` (initial, large file)
- [ ] Test smart mode works
- [ ] Extract `DifficultyPresetButtons.tsx`
- [ ] Extract `DifficultyVisualization.tsx`
- [ ] Extract `RegroupingSlider.tsx`
- [ ] Extract `ScaffoldingSlider.tsx`
- [ ] Update SmartModeControls to use sub-components
- [ ] Run `npm run pre-commit`
- [ ] Manual test: Verify smart mode fully functional
- [ ] Commit: "refactor(worksheets): extract smart mode controls"
### Phase 4: Extract Manual Mode Section ✅ NOT STARTED
- [ ] Extract `ManualModeControls.tsx` (orchestrator)
- [ ] Extract `DisplayOptionsSection.tsx`
- [ ] Extract `RegroupingFrequencySection.tsx`
- [ ] Update ManualModeControls to use sub-components
- [ ] Run `npm run pre-commit`
- [ ] Manual test: Verify manual mode fully functional
- [ ] Commit: "refactor(worksheets): extract manual mode controls"
### Phase 5: Finalize Main ConfigPanel ✅ NOT STARTED
- [ ] Simplify main ConfigPanel.tsx to orchestrator
- [ ] Remove all extracted code
- [ ] Verify clean, minimal component (~150 lines)
- [ ] Run `npm run pre-commit`
- [ ] Manual test: Full smoke test of both modes
- [ ] Commit: "refactor(worksheets): finalize ConfigPanel refactoring"
## Notes
- User has manually tested the worksheet system works before refactoring
- All subtraction scaffolding integration is complete and committed
- Safe restore point: `git reset --hard ab3e5a20`

View File

@@ -5,7 +5,6 @@ This document describes the production deployment infrastructure and procedures
## Infrastructure Overview
### Production Server
- **Host**: `nas.home.network` (Synology NAS DS923+)
- **Access**: SSH access required
- Must be connected to network at **730 N. Oak Park Ave**
@@ -13,53 +12,24 @@ This document describes the production deployment infrastructure and procedures
- **Project Directory**: `/volume1/homes/antialias/projects/abaci.one`
### Docker Configuration
- **Docker binary**: `/usr/local/bin/docker`
- **Docker Compose binary**: `/usr/local/bin/docker-compose`
- **Container name**: `soroban-abacus-flashcards`
- **Image**: `ghcr.io/antialias/soroban-abacus-flashcards:latest`
This deployment uses **two separate Docker Compose projects**:
1. **soroban-app** (`docker-compose.yaml`)
- Main web application
- Container: `soroban-abacus-flashcards`
- Image: `ghcr.io/antialias/soroban-abacus-flashcards:main`
- Port: 3000 (internal to Docker network)
2. **soroban-updater** (`docker-compose.updater.yaml`)
- Automatic update service
- Container: `compose-updater`
- Image: `virtualzone/compose-updater:latest`
- Checks for new images every 5 minutes
**Why separate projects?** If compose-updater was in the same project as the app, running `docker-compose down` would kill itself mid-update. Separate projects prevent this.
### Auto-Deployment with compose-updater
- **compose-updater** monitors and auto-updates containers
- **Update frequency**: Every **5 minutes** (configurable via `INTERVAL=5`)
- Works WITH docker-compose files (respects configuration, volumes, environment variables)
- Automatically cleans up old images (`CLEANUP=1`)
### Auto-Deployment
- **Watchtower** monitors and auto-updates containers
- **Update frequency**: Every **5 minutes**
- Watchtower pulls latest images and restarts containers automatically
- No manual intervention required for deployments after pushing to main
**Key advantages over Watchtower:**
- Respects docker-compose.yaml configuration
- Re-reads `.env` file on every update
- Can manage multiple docker-compose projects
- Container labels control which containers to watch:
```yaml
labels:
- "docker-compose-watcher.watch=1"
- "docker-compose-watcher.dir=/volume1/homes/antialias/projects/abaci.one"
- "com.centurylinklabs.watchtower.enable=false" # Disables Watchtower for this container
```
## Database Management
### Location
- **Database path**: `data/sqlite.db` (relative to project directory)
- **WAL files**: `data/sqlite.db-shm` and `data/sqlite.db-wal`
### Migrations
- **Automatic**: Migrations run on server startup via `server.js`
- **Migration folder**: `./drizzle`
- **Process**:
@@ -70,7 +40,6 @@ This deployment uses **two separate Docker Compose projects**:
5. Logs: `❌ Migration failed: [error]` (on failure, process exits)
### Nuke and Rebuild Database
If you need to completely reset the production database:
```bash
@@ -96,7 +65,6 @@ rm -f data/sqlite.db data/sqlite.db-shm data/sqlite.db-wal
## CI/CD Pipeline
### GitHub Actions
When code is pushed to `main` branch:
1. **Workflows triggered**:
@@ -106,171 +74,92 @@ When code is pushed to `main` branch:
- `Deploy Storybooks to GitHub Pages` - Publishes Storybook
2. **Image build**:
- Built image is tagged as `main` (also `latest` for compatibility)
- Built image is tagged as `latest`
- Pushed to GitHub Container Registry (ghcr.io)
- Typically completes within 1-2 minutes
3. **Deployment**:
- compose-updater detects new image (within 5 minutes)
- Pulls new image
- Runs `docker-compose down && docker-compose up -d`
- Cleans up old images
- Total deployment time: ~5-7 minutes from push to production (15-30 seconds downtime during restart)
- Watchtower detects new image (within 5 minutes)
- Pulls latest image
- Recreates and restarts container
- Total deployment time: ~5-7 minutes from push to production
## Manual Deployment Procedures
### Force Pull Latest Image
If you need to immediately deploy without waiting for compose-updater's next check cycle:
If you need to immediately deploy without waiting for Watchtower:
```bash
# Option 1: Restart compose-updater (triggers immediate check)
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose -f docker-compose.updater.yaml restart"
# Option 2: Manual pull and restart
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose pull && docker-compose up -d"
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && /usr/local/bin/docker-compose pull && /usr/local/bin/docker-compose up -d"
```
### Check Container Status
```bash
# Check both app and compose-updater
ssh nas.home.network "docker ps | grep -E '(soroban|compose)'"
# Check just the app
ssh nas.home.network "docker ps | grep soroban-abacus-flashcards"
ssh nas.home.network "/usr/local/bin/docker ps | grep -E '(soroban|abaci)'"
```
### View Logs
```bash
# Application logs - recent
ssh nas.home.network "docker logs --tail 100 soroban-abacus-flashcards"
# Recent logs
ssh nas.home.network "/usr/local/bin/docker logs --tail 100 soroban-abacus-flashcards"
# Application logs - follow in real-time
ssh nas.home.network "docker logs -f soroban-abacus-flashcards"
# compose-updater logs - see update activity
ssh nas.home.network "docker logs --tail 50 compose-updater"
# compose-updater logs - follow to watch for updates
ssh nas.home.network "docker logs -f compose-updater"
# Follow logs in real-time
ssh nas.home.network "/usr/local/bin/docker logs -f soroban-abacus-flashcards"
# Search for specific patterns
ssh nas.home.network "docker logs soroban-abacus-flashcards" | grep -i "error"
ssh nas.home.network "/usr/local/bin/docker logs soroban-abacus-flashcards" | grep -i "error"
```
### Restart Container
```bash
# Restart just the app (quick, minimal downtime)
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose restart"
# Full restart (down then up, recreates container)
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose down && docker-compose up -d"
# Restart compose-updater (triggers immediate update check)
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose -f docker-compose.updater.yaml restart"
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && /usr/local/bin/docker-compose restart"
```
## Checking Deployed Version
## Deployment Script
Always verify what's actually running in production:
```bash
# Get commit SHA of running container
ssh nas.home.network 'docker inspect soroban-abacus-flashcards --format="{{index .Config.Labels \"org.opencontainers.image.revision\"}}"'
# Compare with current HEAD
git rev-parse HEAD
# Or check via the deployment info modal in the app UI
```
The project includes a deployment script at `nas-deployment/deploy.sh` for manual deployments.
## Troubleshooting
### Common Issues
#### 1. Migration Failures
**Symptom**: Container keeps restarting, logs show migration errors
**Solution**:
1. Check migration files in `drizzle/` directory
2. Verify `drizzle/meta/_journal.json` is up to date
3. If migrations are corrupted, may need to nuke database (see above)
#### 2. Container Not Updating
**Symptom**: Changes pushed but production still shows old code
**Possible causes**:
- GitHub Actions build failed - check workflow status with `gh run list`
- compose-updater not running - check with `docker ps | grep compose-updater`
- compose-updater labels incorrect - check container labels
- Watchtower not running - check with `docker ps | grep watchtower`
- Image not pulled - manually pull with `docker-compose pull`
- **compose-updater detection issue** - May not detect updates reliably (investigation ongoing - 2025-11-13)
**Debugging**:
```bash
# Check compose-updater is running
ssh nas.home.network "docker ps | grep compose-updater"
# Check compose-updater logs for errors and pull activity
ssh nas.home.network "docker logs --tail 50 compose-updater"
# Look for: "Processing service" followed by pull activity
# If it says "No need to restart" WITHOUT pulling, detection may be broken
# Check container labels are correct
ssh nas.home.network "docker inspect soroban-abacus-flashcards" | grep -A3 "docker-compose-watcher"
# Should show:
# "docker-compose-watcher.watch": "1"
# "docker-compose-watcher.dir": "/volume1/homes/antialias/projects/abaci.one"
```
**Known Issue (2025-11-13)**:
compose-updater sometimes fails to detect updates even when new images are available. Logs show:
```
Processing service soroban-abacus-flashcards (requires build: false, watched: true)...
No need to restart services in /volume1/homes/antialias/projects/abaci.one/docker-compose.yaml
```
Without any `docker pull` activity shown, even with `LOG_LEVEL=debug`. This suggests it's determining "no update needed" without actually checking the remote registry. Root cause under investigation.
**Solution**:
```bash
# Option 1: Manual pull and restart (most reliable)
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose pull && docker-compose up -d"
# Option 2: Restart compose-updater to force immediate check (may not always work)
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose -f docker-compose.updater.yaml restart"
# Force pull and restart
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && /usr/local/bin/docker-compose pull && /usr/local/bin/docker-compose up -d"
```
#### 3. Missing Database Columns
**Symptom**: Errors like `SqliteError: no such column: "column_name"`
**Cause**: Migration not registered or not run
**Solution**:
1. Verify migration exists in `drizzle/` directory
2. Check migration is registered in `drizzle/meta/_journal.json`
3. If migration is new, restart container to run migrations
4. If migration is malformed, fix it and nuke database
#### 4. API Returns Unexpected Response
**Symptom**: Client shows errors but API appears to work
**Debugging**:
1. Test API directly with curl: `curl -X POST 'https://abaci.one/api/arcade/rooms' -H 'Content-Type: application/json' -d '...'`
2. Check production logs for errors
3. Verify container is running latest image:
@@ -281,26 +170,11 @@ ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-c
## Environment Variables
Production environment variables are stored in `.env` file on the server and loaded via `env_file:` in docker-compose.yaml.
Production environment variables are configured in the docker-compose.yml file on the server. Common variables:
**Critical advantage**: compose-updater re-reads the `.env` file on every update, so environment variable changes are automatically picked up without manual intervention.
Common variables:
- `AUTH_URL` - Base URL (https://abaci.one)
- `AUTH_SECRET` - Random secret for sessions (NEVER share!)
- `AUTH_TRUST_HOST=true` - Required for NextAuth v5
- `DATABASE_URL` - SQLite database path (optional, defaults to `./data/sqlite.db`)
To update environment variables:
```bash
# Edit .env file on NAS
ssh nas.home.network "vi /volume1/homes/antialias/projects/abaci.one/.env"
# Restart compose-updater (will pick up new .env on next cycle)
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose -f docker-compose.updater.yaml restart"
```
- `NEXT_PUBLIC_URL` - Base URL for the application
- `DATABASE_URL` - SQLite database path
- Additional variables may be set in `.env.production` or docker-compose.yml
## Network Configuration

View File

@@ -5,13 +5,11 @@
**Mission:** Fill the gap in the USA school system by providing a complete, self-directed abacus curriculum that trains students from beginner to mastery using the Japanese kyu/dan ranking system.
**Target Users:**
- Primary: Elementary school students (ages 6-12)
- Secondary: Middle school students and adult learners
- Teachers/Parents: Dashboard for monitoring progress
**Core Experience Principles:**
1. **Integrated Learning Loop:** Tutorial → Practice → Play → Assessment → Progress
2. **Self-Directed:** Simple enough for kids to fire up and start learning independently
3. **Gamified Progression:** Games reinforce lessons, feel like play but teach skills
@@ -25,7 +23,6 @@
### ✅ What We Have (Well-Built)
**1. Interactive Abacus Component (AbacusReact)**
- Highly polished, production-ready
- Excellent pedagogical features (bead highlighting, direction arrows, tooltips)
- Multiple color schemes and accessibility options
@@ -33,7 +30,6 @@
- **Rating: 95% Complete**
**2. Game System (4 Games)**
- Memory Lightning (memorization skills)
- Matching Pairs Battle (pattern recognition, complements)
- Card Sorting (visual literacy, ordering)
@@ -43,7 +39,6 @@
- **Rating: 80% Complete** (games exist but need curriculum integration)
**3. Tutorial Infrastructure**
- Tutorial player with step-based guidance
- Tutorial editor for content creation
- Bead highlighting system for instruction
@@ -51,14 +46,12 @@
- **Rating: 70% Complete** (infrastructure exists but lacks content)
**4. Real-time Multiplayer**
- Socket.IO integration
- Room-based architecture
- State synchronization
- **Rating: 90% Complete**
**5. Flashcard Generator**
- PDF/PNG/SVG export
- Customizable layouts and themes
- **Rating: 100% Complete**
@@ -66,7 +59,6 @@
### ⚠️ What We Have (Partially Built)
**1. Progress Tracking**
- Basic user stats (games played, wins, accuracy)
- No skill-level tracking
- No tutorial completion tracking
@@ -74,14 +66,12 @@
- **Rating: 30% Complete**
**2. Tutorial Content**
- One example tutorial (GuidedAdditionTutorial)
- Type system for tutorials defined
- No comprehensive curriculum
- **Rating: 15% Complete**
**3. Assessment System**
- Per-game scoring exists
- Achievement system exists
- No formal tests or certification
@@ -106,7 +96,6 @@
### Beginner Levels (Kyu)
**10 Kyu - "First Steps"**
- Age: 6-7 years
- Skills: Basic bead manipulation, numbers 1-10
- Curriculum: Recognize and set numbers on abacus, understand place value
@@ -114,60 +103,51 @@
- Games: Card Sorting (visual recognition), Memory Lightning (basic)
**9 Kyu - "Number Explorer"**
- Skills: Addition/subtraction with no carry (1-9)
- Curriculum: Friends of 5 concept introduction
- Assessment: 20 problems, 2-digit addition/subtraction, no carry, 80% accuracy
- Games: Complement Race (practice mode), Matching Pairs (numerals)
**8 Kyu - "Complement Apprentice"**
- Skills: Friends of 5 mastery, introduction to friends of 10
- Curriculum: All combinations that make 5, carry concepts
- Assessment: 30 problems including carries using friends of 5, 85% accuracy
- Games: Complement Race (friends-5 sprint), Matching Pairs (complement pairs)
**7 Kyu - "Addition Warrior"**
- Skills: Friends of 10 mastery, 2-digit addition/subtraction with carries
- Curriculum: All combinations that make 10, mixed complement strategies
- Assessment: 40 problems, 2-3 digit calculations, mixed operations, 85% accuracy
- Games: Complement Race (friends-10 sprint), All games at medium difficulty
**6 Kyu - "Speed Calculator"**
- Skills: Multi-digit addition/subtraction (3-4 digits), speed emphasis
- Curriculum: Chain calculations, mental imagery beginning
- Assessment: 50 problems, 3-4 digits, 3 minutes time limit, 90% accuracy
- Games: Complement Race (survival mode), Memory Lightning (medium)
**5 Kyu - "Multiplication Initiate"**
- Skills: Single-digit multiplication (1-5)
- Curriculum: Multiplication tables 1-5, abacus multiplication method
- Assessment: 30 multiplication problems, 40 add/subtract problems, 90% accuracy
- Games: All games at hard difficulty
**4 Kyu - "Multiplication Master"**
- Skills: Full multiplication tables (1-9), 2-digit × 1-digit
- Curriculum: All multiplication patterns, division introduction
- Assessment: 40 multiplication, 20 division, 40 add/subtract, 90% accuracy
**3 Kyu - "Division Explorer"**
- Skills: Division mastery (2-digit ÷ 1-digit), mixed operations
- Curriculum: Division algorithm, remainders, mixed problem solving
- Assessment: 100 mixed problems in 10 minutes, 92% accuracy
**2 Kyu - "Advanced Operator"**
- Skills: Multi-digit multiplication/division, decimals introduction
- Curriculum: 3-digit × 2-digit, decimals, percentages
- Assessment: 120 mixed problems including decimals, 10 minutes, 93% accuracy
**1 Kyu - "Pre-Mastery"**
- Skills: Decimal operations, fractions, complex multi-step problems
- Curriculum: Real-world applications, word problems
- Assessment: 150 mixed problems, 10 minutes, 95% accuracy
@@ -176,18 +156,15 @@
### Master Levels (Dan)
**1 Dan - "Shodan" (First Degree)**
- Skills: Mental imagery without abacus, complex calculations
- Assessment: 200 mixed problems, 10 minutes, 96% accuracy
- Mental arithmetic certification
**2 Dan - "Nidan"**
- Skills: Advanced mental calculation, speed competitions
- Assessment: 250 problems, 10 minutes, 97% accuracy
**3 Dan - "Sandan"**
- Skills: Championship-level speed and accuracy
- Assessment: 300 problems, 10 minutes, 98% accuracy
@@ -211,12 +188,10 @@
### Example: Teaching "Friends of 5"
**1. Assessment (Placement)**
- Quick quiz: "Can you add 3 + 4 using the abacus?"
- Result: Student struggles → Assign Friends of 5 tutorial
**2. Learn (Tutorial)**
- Interactive tutorial: "Friends of 5"
- Steps:
1. Show that 5 = 1+4, 2+3, 3+2, 4+1
@@ -226,27 +201,23 @@
5. Practice all combinations
**3. Practice (Structured Exercises)**
- 20 problems: Set number, add its friend
- Real-time feedback on bead movements
- Hints available: "Use the heaven bead!"
- Must achieve 90% accuracy to proceed
**4. Play (Game Reinforcement)**
- Complement Race: Friends-5 mode
- Matching Pairs: Match numbers that make 5
- Makes practice feel like play
**5. Test (Formal Assessment)**
- 30 problems mixing friends-5 with previous skills
- Timed: 5 minutes
- Must achieve 85% to certify skill
- Can retake after reviewing mistakes
**6. Advance (Progress Update)**
- Friends of 5 skill marked as "Mastered"
- Unlock: Friends of 10 tutorial
- Update skill matrix
@@ -261,76 +232,76 @@
```typescript
// Skill taxonomy
enum SkillCategory {
NUMBER_SENSE = "number-sense",
ADDITION = "addition",
SUBTRACTION = "subtraction",
MULTIPLICATION = "multiplication",
DIVISION = "division",
MENTAL_CALC = "mental-calculation",
COMPLEMENTS = "complements",
SPEED = "speed",
ACCURACY = "accuracy",
NUMBER_SENSE = 'number-sense',
ADDITION = 'addition',
SUBTRACTION = 'subtraction',
MULTIPLICATION = 'multiplication',
DIVISION = 'division',
MENTAL_CALC = 'mental-calculation',
COMPLEMENTS = 'complements',
SPEED = 'speed',
ACCURACY = 'accuracy'
}
// Individual skill (atomic unit)
interface Skill {
id: string;
name: string;
category: SkillCategory;
kyuLevel: number; // Which kyu level this skill belongs to
prerequisiteSkills: string[]; // Must master these first
description: string;
estimatedPracticeTime: number; // minutes
id: string
name: string
category: SkillCategory
kyuLevel: number // Which kyu level this skill belongs to
prerequisiteSkills: string[] // Must master these first
description: string
estimatedPracticeTime: number // minutes
}
// Learning module (collection of related skills)
interface Module {
id: string;
title: string;
kyuLevel: number;
description: string;
skills: string[]; // Skill IDs
estimatedCompletionTime: number; // hours
sequence: number; // Order within kyu level
id: string
title: string
kyuLevel: number
description: string
skills: string[] // Skill IDs
estimatedCompletionTime: number // hours
sequence: number // Order within kyu level
}
// Tutorial (teaches one or more skills)
interface Tutorial {
id: string;
skillIds: string[];
moduleId: string;
type: "interactive" | "video" | "reading";
content: TutorialStep[];
estimatedDuration: number;
id: string
skillIds: string[]
moduleId: string
type: 'interactive' | 'video' | 'reading'
content: TutorialStep[]
estimatedDuration: number
}
// Practice set (reinforces skills)
interface PracticeSet {
id: string;
skillIds: string[];
problemCount: number;
timeLimit?: number;
passingAccuracy: number;
difficulty: "easy" | "medium" | "hard";
id: string
skillIds: string[]
problemCount: number
timeLimit?: number
passingAccuracy: number
difficulty: 'easy' | 'medium' | 'hard'
}
// Game mapping (which games teach which skills)
interface GameSkillMapping {
gameId: string;
skillIds: string[];
difficulty: string;
recommendedKyuRange: [number, number];
gameId: string
skillIds: string[]
difficulty: string
recommendedKyuRange: [number, number]
}
// Assessment (formal test)
interface Assessment {
id: string;
type: "placement" | "skill-check" | "kyu-certification";
kyuLevel?: number;
skillIds: string[];
problemCount: number;
timeLimit: number;
passingAccuracy: number;
id: string
type: 'placement' | 'skill-check' | 'kyu-certification'
kyuLevel?: number
skillIds: string[]
problemCount: number
timeLimit: number
passingAccuracy: number
}
```
@@ -387,7 +358,6 @@ interface Assessment {
**Goal:** Students can learn and certify 10 Kyu and 9 Kyu levels
**Database Schema Updates:**
- [ ] Create `skills` table
- [ ] Create `modules` table
- [ ] Create `curriculum_tutorials` table (links tutorials to skills)
@@ -402,7 +372,6 @@ interface Assessment {
- [ ] Extend `user_stats` table: add `currentKyuLevel`, `currentDanLevel`, `skillsMastered`
**Tutorial Content Creation:**
- [ ] 10 Kyu tutorials (5 tutorials):
1. Introduction to Abacus
2. Understanding Place Value
@@ -415,14 +384,12 @@ interface Assessment {
3. Friends of 5 - Subtraction
**Practice Sets:**
- [ ] Build practice set generator for each skill
- [ ] Implement immediate feedback system
- [ ] Add hint system for common mistakes
- [ ] Track accuracy and time per problem
**Assessment System:**
- [ ] Build placement test component (determines starting level)
- [ ] Build skill-check test component (practice test before certification)
- [ ] Build kyu certification test component (formal test)
@@ -431,7 +398,6 @@ interface Assessment {
- [ ] Allow test retakes with review of mistakes
**Game Integration:**
- [ ] Map existing games to skills
- Memory Lightning → Number recognition, memory
- Card Sorting → Visual pattern recognition, ordering
@@ -441,7 +407,6 @@ interface Assessment {
- [ ] Track game performance per skill
**Student Dashboard:**
- [ ] Create dashboard showing:
- Current kyu level
- Skills mastered / in progress / locked
@@ -452,7 +417,6 @@ interface Assessment {
- [ ] Add celebratory animations for milestones
**Core User Flow:**
- [ ] Onboarding: Placement test → Assign kyu level
- [ ] Home: Dashboard shows next recommended activity
- [ ] Click "Start Learning" → Next tutorial
@@ -463,7 +427,6 @@ interface Assessment {
- [ ] Celebration and badge award
**Deliverables:**
- Students can complete 10 Kyu and 9 Kyu
- ~8 tutorials
- ~10 skills defined
@@ -478,7 +441,6 @@ interface Assessment {
**Goal:** Complete beginner curriculum through multiplication introduction
**Content Creation:**
- [ ] 8 Kyu: Friends of 10 tutorials and practice (4 weeks)
- [ ] 7 Kyu: Mixed complements, 2-digit operations (4 weeks)
- [ ] 6 Kyu: Multi-digit, speed training (6 weeks)
@@ -486,7 +448,6 @@ interface Assessment {
- Total: ~40 tutorials, ~30 skills
**Enhanced Features:**
- [ ] Adaptive difficulty in practice sets (adjusts based on performance)
- [ ] Spaced repetition system (review mastered skills periodically)
- [ ] Daily recommended practice (10-15 min sessions)
@@ -494,13 +455,11 @@ interface Assessment {
- [ ] Peer comparison (anonymous, optional)
**New Games:**
- [ ] Multiplication tables game
- [ ] Speed drill game (flash calculation)
- [ ] Mental math game (visualization without physical abacus)
**Parent/Teacher Dashboard:**
- [ ] View student progress
- [ ] See time spent learning
- [ ] Review test results
@@ -508,7 +467,6 @@ interface Assessment {
- [ ] Generate progress reports
**Gamification Enhancements:**
- [ ] Achievement badges for milestones
- [ ] Experience points (XP) system
- [ ] Level-up animations
@@ -516,7 +474,6 @@ interface Assessment {
- [ ] Virtual rewards (stickers, themes)
**Deliverables:**
- Complete 8-5 Kyu curriculum
- ~50 total tutorials (cumulative)
- ~40 total skills (cumulative)
@@ -531,7 +488,6 @@ interface Assessment {
**Goal:** Advanced operations, real-world applications, mental calculation
**Content Creation:**
- [ ] 4 Kyu: Full multiplication, division introduction (8 weeks)
- [ ] 3 Kyu: Division mastery, mixed operations (8 weeks)
- [ ] 2 Kyu: Decimals, percentages (10 weeks)
@@ -539,35 +495,30 @@ interface Assessment {
- Total: ~60 additional tutorials, ~40 additional skills
**Mental Calculation Training:**
- [ ] Visualization exercises (see abacus in mind)
- [ ] Flash anzan (rapid mental calculation)
- [ ] Mental calculation games
- [ ] Transition from physical to mental abacus
**Real-World Applications:**
- [ ] Shopping math (money, change, discounts)
- [ ] Measurement conversions
- [ ] Time calculations
- [ ] Real-world word problems
**Competition Features:**
- [ ] Speed competitions (leaderboards)
- [ ] Accuracy challenges
- [ ] Weekly tournaments
- [ ] Regional/global rankings (optional)
**AI Tutor Assistant:**
- [ ] Smart hints during practice
- [ ] Personalized learning paths
- [ ] Concept explanations on demand
- [ ] Answer specific questions ("Why do I use friends of 5 here?")
**Deliverables:**
- Complete 4-1 Kyu curriculum
- ~110 total tutorials (cumulative)
- ~80 total skills (cumulative)
@@ -582,14 +533,12 @@ interface Assessment {
**Goal:** Championship-level speed and accuracy, mental calculation mastery
**Content Creation:**
- [ ] Dan level certification tests
- [ ] Advanced mental calculation curriculum
- [ ] Championship preparation materials
- [ ] Expert-level problem sets
**Advanced Features:**
- [ ] Customized training plans for dan levels
- [ ] Video lessons from expert abacus users
- [ ] Community forum for advanced learners
@@ -597,13 +546,11 @@ interface Assessment {
- [ ] Certification/diploma generation (printable)
**Integration with Standards:**
- [ ] Align with League of Soroban of Americas standards
- [ ] Japan Abacus Committee certification mapping
- [ ] International competition preparation
**Deliverables:**
- 1-10 Dan curriculum
- Certification system
- Community features
@@ -614,14 +561,12 @@ interface Assessment {
### Phase 5: Ecosystem (Months 18+) - "Complete Platform"
**Content Management System:**
- [ ] Tutorial builder UI (create without code)
- [ ] Content versioning
- [ ] Community-contributed content (vetted)
- [ ] Multilingual support (Spanish, Japanese, Hindi)
**Classroom Features:**
- [ ] Teacher creates classes
- [ ] Bulk student enrollment
- [ ] Class-wide assignments
@@ -629,7 +574,6 @@ interface Assessment {
- [ ] Live teaching mode (project for class)
**Analytics & Insights:**
- [ ] Student learning velocity
- [ ] Skill gap analysis
- [ ] Predictive success modeling
@@ -637,20 +581,17 @@ interface Assessment {
- [ ] Export data for research
**Mobile App:**
- [ ] iOS and React Native apps
- [ ] Offline mode
- [ ] Sync across devices
**Integrations:**
- [ ] Google Classroom
- [ ] Canvas LMS
- [ ] Schoology
- [ ] Export to SIS systems
**Advanced Gamification:**
- [ ] Story mode (learning quest)
- [ ] Cooperative challenges
- [ ] Guild/team system
@@ -661,7 +602,6 @@ interface Assessment {
## Success Metrics
### Student Engagement
- **Daily Active Users (DAU):** Target 40% of registered students
- **Weekly Active Users (WAU):** Target 70% of registered students
- **Average session time:** 20-30 minutes
@@ -670,27 +610,23 @@ interface Assessment {
- **Streak length:** Average 7+ days
### Learning Outcomes
- **Certification pass rate:** >70% on first attempt per kyu level
- **Skill mastery rate:** >85% accuracy on mastered skills after 30 days
- **Time to mastery:** Track average time per kyu level
- **Progression velocity:** Students advance 1 kyu level per 4-8 weeks (varies by level)
### Content Quality
- **Tutorial completion rate:** >90%
- **Practice set completion rate:** >85%
- **Game play rate:** >60% of students play games weekly
- **Assessment completion rate:** >75%
### Platform Health
- **System uptime:** >99.5%
- **Load time:** <2 seconds
- **Error rate:** <0.1%
### Business/Growth
- **Monthly signups:** Track growth month-over-month
- **Paid conversion** (if applicable): Target 10-20%
- **Teacher/school adoption:** Track institutional users
@@ -703,7 +639,6 @@ interface Assessment {
### Database Changes Priority
**Immediate (Phase 1):**
```sql
-- Skills and curriculum structure
CREATE TABLE skills (...)
@@ -726,13 +661,11 @@ CREATE TABLE game_skill_mappings (...)
```
**Phase 2:**
- Add spaced repetition tables
- Achievement tracking enhancements
- Peer comparison data
**Phase 3:**
- Mental calculation tracking
- Competition results
- AI tutor interaction logs
@@ -740,21 +673,18 @@ CREATE TABLE game_skill_mappings (...)
### API Endpoints Needed
**Progress & Skills:**
- `GET /api/student/progress` - Current kyu level, skills, next steps
- `GET /api/student/skills/:skillId` - Skill details and progress
- `POST /api/student/skills/:skillId/practice` - Record practice attempt
- `GET /api/student/dashboard` - Dashboard data
**Curriculum:**
- `GET /api/curriculum/kyu/:level` - All modules for kyu level
- `GET /api/curriculum/modules/:moduleId` - Module details
- `GET /api/curriculum/tutorials/:tutorialId` - Tutorial content
- `GET /api/curriculum/next` - Next recommended activity
**Assessments:**
- `POST /api/assessments/placement` - Take placement test
- `POST /api/assessments/skill-check/:skillId` - Practice test
- `POST /api/assessments/certification/:kyuLevel` - Certification test
@@ -762,13 +692,11 @@ CREATE TABLE game_skill_mappings (...)
- `GET /api/assessments/:assessmentId/results` - Get results
**Games:**
- `GET /api/games/recommended` - Games for current skills
- `POST /api/games/:gameId/result` - Log game completion
- `GET /api/games/:gameId/skills` - Which skills this game teaches
**Teacher/Parent:**
- `GET /api/teacher/students` - List of students
- `GET /api/teacher/students/:studentId/progress` - Student progress
- `POST /api/teacher/assignments` - Create assignment
@@ -932,28 +860,24 @@ CREATE TABLE game_skill_mappings (...)
## Next Immediate Steps
### Week 1: Database Schema Design
- [ ] Design complete schema for Phase 1
- [ ] Write migration scripts
- [ ] Document schema decisions
- [ ] Review with stakeholders
### Week 2-3: Content Planning
- [ ] Write detailed 10 Kyu curriculum outline
- [ ] Write detailed 9 Kyu curriculum outline
- [ ] Define all skills for 10-9 Kyu
- [ ] Map skills to existing games
### Week 4-5: Tutorial Content Creation
- [ ] Write 5 tutorials for 10 Kyu
- [ ] Write 3 tutorials for 9 Kyu
- [ ] Create interactive steps with highlighting
- [ ] Add kid-friendly explanations
### Week 6-7: Assessment System Build
- [ ] Build assessment component UI
- [ ] Implement grading engine
- [ ] Create placement test (20 problems)
@@ -961,21 +885,18 @@ CREATE TABLE game_skill_mappings (...)
- [ ] Create 9 Kyu certification test (40 problems)
### Week 8-9: Practice System
- [ ] Build practice session component
- [ ] Implement problem generator for each skill
- [ ] Add immediate feedback system
- [ ] Create hint system
### Week 10-11: Student Dashboard
- [ ] Design dashboard UI (kid-friendly)
- [ ] Build progress visualization
- [ ] Implement "next recommended activity" logic
- [ ] Add achievement display
### Week 12: Integration & Testing
- [ ] Connect all pieces: tutorials → practice → games → assessment
- [ ] Test complete user flow
- [ ] User testing with kids
@@ -1003,7 +924,6 @@ CREATE TABLE game_skill_mappings (...)
This roadmap provides a clear path from current state (scattered features) to target state (complete educational platform). The phased approach allows incremental delivery while maintaining focus on core learning experience.
**Estimated Timeline:**
- Phase 1 (10-9 Kyu MVP): 3 months
- Phase 2 (8-5 Kyu): 5 months
- Phase 3 (4-1 Kyu): 6 months

View File

@@ -1,202 +0,0 @@
# Arcade Error Handling System
## Overview
Comprehensive error handling system for arcade games to ensure users always see meaningful error messages instead of silent failures.
## Components
### 1. ErrorToast (`src/components/ErrorToast.tsx`)
User-facing error notification component:
- Prominent red toast in bottom-right corner
- Auto-dismisses after 10 seconds
- Collapsible technical details
- Mobile-responsive
**Usage:**
```typescript
<ErrorToast
message="Game session error"
details="Error: Failed to fetch session..."
onDismiss={() => clearError(errorId)}
/>
```
### 2. ArcadeErrorBoundary (`src/components/ArcadeErrorBoundary.tsx`)
React error boundary for catching React errors:
- Catches component render errors
- Shows user-friendly fallback UI
- Provides "Try Again" and "Return to Lobby" buttons
- Collapsible stack trace for debugging
**Usage:**
```typescript
<ArcadeErrorBoundary>
<GameComponent />
</ArcadeErrorBoundary>
```
### 3. ArcadeErrorContext (`src/contexts/ArcadeErrorContext.tsx`)
Global error management context:
- Manages error state across the app
- Renders error toasts
- Auto-cleans up old errors
**Usage:**
```typescript
// Wrap your app/page
<ArcadeErrorProvider>
{children}
</ArcadeErrorProvider>
// Use in components
const { addError, clearError } = useArcadeError()
addError('Something went wrong', 'Technical details...')
```
### 4. Enhanced useArcadeSocket Hook
Socket hook now automatically shows error toasts for:
- **Connection errors**: Failed to connect to server
- **Disconnections**: Connection lost
- **Session errors**: Failed to load/update session
- **Move rejections**: Invalid moves (non-version-conflict)
- **No active session**: Session not found
Can suppress toasts with `suppressErrorToasts: true` option.
## Error Categories
### Network/Connection Errors
- **Connection error**: Failed to connect to game server
- **Disconnection**: Connection lost, attempting to reconnect
### Session Errors
- **Session error**: Failed to load or update game session
- **No active session**: No game session found
### Game State Errors
- **Move rejected**: Invalid move submitted
- **Version conflict**: Concurrent update detected (silent, not shown to user)
### React Errors
- **Component errors**: Caught by ErrorBoundary, shows fallback UI
## Integration Guide
### For New Arcade Games
1. **Wrap your game page with error providers:**
```typescript
// src/app/arcade/your-game/page.tsx
import { ArcadeErrorProvider } from '@/contexts/ArcadeErrorContext'
import { ArcadeErrorBoundary } from '@/components/ArcadeErrorBoundary'
export default function YourGamePage() {
return (
<ArcadeErrorProvider>
<ArcadeErrorBoundary>
<YourGameProvider>
<YourGameComponent />
</YourGameProvider>
</ArcadeErrorBoundary>
</ArcadeErrorProvider>
)
}
```
2. **Use error context in your components:**
```typescript
import { useArcadeError } from "@/contexts/ArcadeErrorContext";
function YourComponent() {
const { addError } = useArcadeError();
try {
// Your code
} catch (error) {
addError("User-friendly message", `Technical details: ${error.message}`);
}
}
```
3. **Socket hook is automatic:**
The `useArcadeSocket` hook already shows errors by default. No changes needed unless you want to suppress them.
## Best Practices
### DO:
- ✅ Use `addError()` for runtime errors
- ✅ Provide user-friendly primary messages
- ✅ Include technical details in the `details` parameter
- ✅ Wrap arcade pages with both ErrorProvider and ErrorBoundary
- ✅ Let socket errors show automatically (they're handled)
### DON'T:
- ❌ Don't just log errors to console
- ❌ Don't show raw error messages to users
- ❌ Don't swallow errors silently
- ❌ Don't use `alert()` for errors
- ❌ Don't forget to wrap new arcade games
## TODO
- [ ] Wrap all arcade game pages with error providers
- [ ] Add error recovery strategies (retry buttons)
- [ ] Add error reporting/telemetry
- [ ] Test all error scenarios
- [ ] Document error codes/types
## Testing Errors
To test error handling:
1. **Connection errors**: Disconnect network, try to join game
2. **Session errors**: Use invalid room ID
3. **Move rejection**: Submit invalid move
4. **React errors**: Throw error in component render
## Example: Know Your World
The know-your-world game had a "Failed to fetch session" error that was only logged to console. With the new system:
**Before:**
- Error logged to console
- User sees nothing, buttons don't work
- No way to know what's wrong
**After:**
- Error toast appears: "Game session error"
- Technical details available (collapsible)
- User can refresh or return to lobby
- Clear actionable feedback
## Migration
Existing arcade games need to be updated to wrap with error providers. Priority order:
1. ✅ useArcadeSocket hook (done)
2. ✅ Error components created (done)
3. ⏳ Wrap arcade game pages
4. ⏳ Test error scenarios
5. ⏳ Add recovery strategies

View File

@@ -21,7 +21,6 @@ CREATE TABLE room_game_configs (
```
**Benefits:**
- ✅ Type-safe config access with shared types
- ✅ Smaller rows (only configs for games that have been used)
- ✅ Easier updates (single row vs entire JSON blob)
@@ -30,7 +29,6 @@ CREATE TABLE room_game_configs (
- ✅ Can query/index individual game settings
**Example Row:**
```json
{
"id": "clxyz123",
@@ -54,35 +52,34 @@ All game configs are defined in `src/lib/arcade/game-configs.ts`:
```typescript
// Shared config types (single source of truth)
export interface MatchingGameConfig {
gameType: "abacus-numeral" | "complement-pairs";
difficulty: number;
turnTimer: number;
gameType: 'abacus-numeral' | 'complement-pairs'
difficulty: number
turnTimer: number
}
export interface MemoryQuizGameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15;
displayTime: number;
selectedDifficulty: DifficultyLevel;
playMode: "cooperative" | "competitive";
selectedCount: 2 | 5 | 8 | 12 | 15
displayTime: number
selectedDifficulty: DifficultyLevel
playMode: 'cooperative' | 'competitive'
}
// Default configs
export const DEFAULT_MATCHING_CONFIG: MatchingGameConfig = {
gameType: "abacus-numeral",
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
};
}
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
selectedCount: 5,
displayTime: 2.0,
selectedDifficulty: "easy",
playMode: "cooperative",
};
selectedDifficulty: 'easy',
playMode: 'cooperative',
}
```
**Why This Matters:**
- TypeScript enforces that validators, helpers, and API routes all use the same types
- Adding a new setting requires changes in only ONE place (the type definition)
- Impossible to forget a setting or use wrong type
@@ -92,50 +89,43 @@ export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
Settings persistence requires coordination between FOUR systems:
### 1. Helper Functions
**Location:** `src/lib/arcade/game-config-helpers.ts`
**Responsibilities:**
- Read/write game configs from `room_game_configs` table
- Provide type-safe access with automatic defaults
- Validate configs at runtime
**Key Functions:**
```typescript
// Get config with defaults (type-safe)
const config = await getGameConfig(roomId, "memory-quiz");
const config = await getGameConfig(roomId, 'memory-quiz')
// Returns: MemoryQuizGameConfig
// Set/update config (upsert)
await setGameConfig(roomId, "memory-quiz", {
playMode: "competitive",
await setGameConfig(roomId, 'memory-quiz', {
playMode: 'competitive',
selectedCount: 8,
});
})
// Get all game configs for a room
const allConfigs = await getAllGameConfigs(roomId);
const allConfigs = await getAllGameConfigs(roomId)
// Returns: { matching?: MatchingGameConfig, 'memory-quiz'?: MemoryQuizGameConfig }
```
### 2. API Routes
**Location:**
- `src/app/api/arcade/rooms/current/route.ts` (read)
- `src/app/api/arcade/rooms/[roomId]/settings/route.ts` (write)
**Responsibilities:**
- Aggregate game configs from database
- Return them to client in `room.gameConfig`
- Write config updates to `room_game_configs` table
**Read Example:** `GET /api/arcade/rooms/current`
```typescript
const gameConfig = await getAllGameConfigs(roomId);
const gameConfig = await getAllGameConfigs(roomId)
return NextResponse.json({
room: {
@@ -144,61 +134,54 @@ return NextResponse.json({
},
members,
memberPlayers,
});
})
```
**Write Example:** `PATCH /api/arcade/rooms/[roomId]/settings`
```typescript
if (body.gameConfig !== undefined) {
// body.gameConfig: { matching: {...}, memory-quiz: {...} }
for (const [gameName, config] of Object.entries(body.gameConfig)) {
await setGameConfig(roomId, gameName, config);
await setGameConfig(roomId, gameName, config)
}
}
```
### 3. Socket Server (Session Creation)
**Location:** `src/socket-server.ts:70-90`
**Responsibilities:**
- Create initial arcade session when user joins room
- Read saved settings using `getGameConfig()` helper
- Pass settings to validator's `getInitialState()`
**Example:**
```typescript
const room = await getRoomById(roomId);
const validator = getValidator(room.gameName as GameName);
const room = await getRoomById(roomId)
const validator = getValidator(room.gameName as GameName)
// Get config from database (type-safe, includes defaults)
const gameConfig = await getGameConfig(roomId, room.gameName as GameName);
const gameConfig = await getGameConfig(roomId, room.gameName as GameName)
// Pass to validator (types match automatically)
const initialState = validator.getInitialState(gameConfig);
const initialState = validator.getInitialState(gameConfig)
await createArcadeSession({ userId, gameName, initialState, roomId });
await createArcadeSession({ userId, gameName, initialState, roomId })
```
**Key Point:** No more manual config extraction or default fallbacks!
### 4. Game Validators
**Location:** `src/lib/arcade/validation/*Validator.ts`
**Responsibilities:**
- Define `getInitialState()` method with shared config type
- Create initial game state from config
- TypeScript enforces all settings are handled
**Example:** `MemoryQuizGameValidator.ts`
```typescript
import type { MemoryQuizGameConfig } from "@/lib/arcade/game-configs";
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
class MemoryQuizGameValidator {
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
@@ -206,59 +189,52 @@ class MemoryQuizGameValidator {
selectedCount: config.selectedCount,
displayTime: config.displayTime,
selectedDifficulty: config.selectedDifficulty,
playMode: config.playMode, // TypeScript ensures this field exists!
playMode: config.playMode, // TypeScript ensures this field exists!
// ...other state
};
}
}
}
```
### 5. Client Providers (Unchanged)
**Location:** `src/app/arcade/{game}/context/Room{Game}Provider.tsx`
**Responsibilities:**
- Read settings from `roomData.gameConfig[gameName]`
- Merge with `initialState` defaults
- Works transparently with new backend structure
**Example:** `RoomMemoryQuizProvider.tsx:211-233`
```typescript
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any>;
const savedConfig = gameConfig?.["memory-quiz"];
const gameConfig = roomData?.gameConfig as Record<string, any>
const savedConfig = gameConfig?.['memory-quiz']
if (!savedConfig) {
return initialState;
return initialState
}
return {
...initialState,
selectedCount: savedConfig.selectedCount ?? initialState.selectedCount,
displayTime: savedConfig.displayTime ?? initialState.displayTime,
selectedDifficulty:
savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
selectedDifficulty: savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
playMode: savedConfig.playMode ?? initialState.playMode,
};
}, [roomData?.gameConfig]);
}
}, [roomData?.gameConfig])
```
## Common Bugs and Solutions
### Bug #1: Settings Not Persisting
**Symptom:** Settings reset to defaults after game switch
**Root Cause:** One of the following:
1. API route not writing to `room_game_configs` table
2. Helper function not being used correctly
3. Validator not using shared config type
**Solution:** Verify the data flow:
```bash
# 1. Check database write
SELECT * FROM room_game_configs WHERE room_id = '...';
@@ -274,13 +250,11 @@ SELECT * FROM room_game_configs WHERE room_id = '...';
```
### Bug #2: TypeScript Errors About Missing Fields
**Symptom:** `Property '{field}' is missing in type ...`
**Root Cause:** Validator's `getInitialState()` signature doesn't match shared config type
**Solution:** Import and use the shared config type:
```typescript
// ❌ WRONG
getInitialState(config: {
@@ -296,19 +270,17 @@ getInitialState(config: MemoryQuizGameConfig): SorobanQuizState
```
### Bug #3: Settings Wiped When Returning to Game Selection
**Symptom:** Settings reset when going back to game selection
**Root Cause:** Sending `gameConfig: null` in PATCH request
**Solution:** Only send `gameName: null`, don't touch gameConfig:
```typescript
// ❌ WRONG
body: JSON.stringify({ gameName: null, gameConfig: null });
body: JSON.stringify({ gameName: null, gameConfig: null })
// ✅ CORRECT
body: JSON.stringify({ gameName: null });
body: JSON.stringify({ gameName: null })
```
## Debugging Checklist
@@ -345,23 +317,22 @@ When a setting doesn't persist:
To add a new setting to an existing game:
1. **Update the shared config type** (`game-configs.ts`):
```typescript
export interface MemoryQuizGameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15;
displayTime: number;
selectedDifficulty: DifficultyLevel;
playMode: "cooperative" | "competitive";
newSetting: string; // ← Add here
selectedCount: 2 | 5 | 8 | 12 | 15
displayTime: number
selectedDifficulty: DifficultyLevel
playMode: 'cooperative' | 'competitive'
newSetting: string // ← Add here
}
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
selectedCount: 5,
displayTime: 2.0,
selectedDifficulty: "easy",
playMode: "cooperative",
newSetting: "default", // ← Add default
};
selectedDifficulty: 'easy',
playMode: 'cooperative',
newSetting: 'default', // ← Add default
}
```
2. **TypeScript will now enforce:**
@@ -370,7 +341,6 @@ export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
- ✅ Client providers will need to handle it
3. **Update the validator** (`*Validator.ts`):
```typescript
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
return {
@@ -399,13 +369,11 @@ Manual test procedure:
## Migration Notes
**Old Schema:**
- Settings stored in `arcade_rooms.game_config` JSON column
- Config stored directly for currently selected game only
- Config lost when switching games
**New Schema:**
- Settings stored in `room_game_configs` table
- One row per game per room
- Unique constraint on (room_id, game_name)
@@ -414,14 +382,12 @@ Manual test procedure:
**Migration:** See `.claude/MANUAL_MIGRATION_0011.md` for complete details
**Summary:**
- Manual migration applied on 2025-10-15
- Created `room_game_configs` table via sqlite3 CLI
- Migrated 6000 existing configs (5991 matching, 9 memory-quiz)
- Table created directly instead of through drizzle migration system
**Rollback Plan:**
- Old `game_config` column still exists in `arcade_rooms` table
- Old data preserved (was only read, not deleted)
- Can revert to reading from old column if needed
@@ -430,31 +396,26 @@ Manual test procedure:
## Architecture Benefits
**Type Safety:**
- Single source of truth for config types
- TypeScript enforces consistency everywhere
- Impossible to forget a setting
**DRY (Don't Repeat Yourself):**
- No duplicated default values
- No manual config extraction
- No manual merging with defaults
**Maintainability:**
- Adding a setting touches fewer places
- Clear separation of concerns
- Easier to trace data flow
**Performance:**
- Smaller database rows
- Better query performance
- Less network payload
**Correctness:**
- Runtime validation available
- Database constraints (unique index)
- Impossible to create duplicate configs

View File

@@ -19,16 +19,16 @@
// src/lib/arcade/game-configs.ts
export interface MatchingGameConfig {
gameType: "abacus-numeral" | "complement-pairs";
difficulty: number;
turnTimer: number;
gameType: 'abacus-numeral' | 'complement-pairs'
difficulty: number
turnTimer: number
}
export interface MemoryQuizGameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15;
displayTime: number;
selectedDifficulty: DifficultyLevel;
playMode: "cooperative" | "competitive";
selectedCount: 2 | 5 | 8 | 12 | 15
displayTime: number
selectedDifficulty: DifficultyLevel
playMode: 'cooperative' | 'competitive'
}
export interface ComplementRaceGameConfig {
@@ -36,28 +36,27 @@ export interface ComplementRaceGameConfig {
}
export interface RoomGameConfig {
matching?: MatchingGameConfig;
"memory-quiz"?: MemoryQuizGameConfig;
"complement-race"?: ComplementRaceGameConfig;
matching?: MatchingGameConfig
'memory-quiz'?: MemoryQuizGameConfig
'complement-race'?: ComplementRaceGameConfig
}
// Default configs
export const DEFAULT_MATCHING_CONFIG: MatchingGameConfig = {
gameType: "abacus-numeral",
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
};
}
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
selectedCount: 5,
displayTime: 2.0,
selectedDifficulty: "easy",
playMode: "cooperative",
};
selectedDifficulty: 'easy',
playMode: 'cooperative',
}
```
**Benefits:**
- Single source of truth for each game's settings
- TypeScript enforces consistency across codebase
- Easy to see what settings each game has
@@ -71,53 +70,47 @@ export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
```typescript
// src/lib/arcade/game-config-helpers.ts
import type { GameName } from "./validation";
import type {
RoomGameConfig,
MatchingGameConfig,
MemoryQuizGameConfig,
} from "./game-configs";
import {
DEFAULT_MATCHING_CONFIG,
DEFAULT_MEMORY_QUIZ_CONFIG,
} from "./game-configs";
import type { GameName } from './validation'
import type { RoomGameConfig, MatchingGameConfig, MemoryQuizGameConfig } from './game-configs'
import { DEFAULT_MATCHING_CONFIG, DEFAULT_MEMORY_QUIZ_CONFIG } from './game-configs'
/**
* Get game-specific config from room's gameConfig with defaults
*/
export function getGameConfig<T extends GameName>(
roomGameConfig: RoomGameConfig | null | undefined,
gameName: T,
): T extends "matching"
gameName: T
): T extends 'matching'
? MatchingGameConfig
: T extends "memory-quiz"
? MemoryQuizGameConfig
: never {
: T extends 'memory-quiz'
? MemoryQuizGameConfig
: never {
if (!roomGameConfig) {
return getDefaultGameConfig(gameName) as any;
return getDefaultGameConfig(gameName) as any
}
const savedConfig = roomGameConfig[gameName];
const savedConfig = roomGameConfig[gameName]
if (!savedConfig) {
return getDefaultGameConfig(gameName) as any;
return getDefaultGameConfig(gameName) as any
}
// Merge saved config with defaults to handle missing fields
const defaults = getDefaultGameConfig(gameName);
return { ...defaults, ...savedConfig } as any;
const defaults = getDefaultGameConfig(gameName)
return { ...defaults, ...savedConfig } as any
}
function getDefaultGameConfig(gameName: GameName) {
switch (gameName) {
case "matching":
return DEFAULT_MATCHING_CONFIG;
case "memory-quiz":
return DEFAULT_MEMORY_QUIZ_CONFIG;
case "complement-race":
case 'matching':
return DEFAULT_MATCHING_CONFIG
case 'memory-quiz':
return DEFAULT_MEMORY_QUIZ_CONFIG
case 'complement-race':
// return DEFAULT_COMPLEMENT_RACE_CONFIG
throw new Error("complement-race config not implemented");
throw new Error('complement-race config not implemented')
default:
throw new Error(`Unknown game: ${gameName}`);
throw new Error(`Unknown game: ${gameName}`)
}
}
@@ -127,16 +120,10 @@ function getDefaultGameConfig(gameName: GameName) {
export function updateGameConfig<T extends GameName>(
currentRoomConfig: RoomGameConfig | null | undefined,
gameName: T,
updates: Partial<
T extends "matching"
? MatchingGameConfig
: T extends "memory-quiz"
? MemoryQuizGameConfig
: never
>,
updates: Partial<T extends 'matching' ? MatchingGameConfig : T extends 'memory-quiz' ? MemoryQuizGameConfig : never>
): RoomGameConfig {
const current = currentRoomConfig || {};
const gameConfig = current[gameName] || getDefaultGameConfig(gameName);
const current = currentRoomConfig || {}
const gameConfig = current[gameName] || getDefaultGameConfig(gameName)
return {
...current,
@@ -144,57 +131,53 @@ export function updateGameConfig<T extends GameName>(
...gameConfig,
...updates,
},
};
}
}
```
**Usage in socket-server.ts:**
```typescript
// BEFORE (error-prone, duplicated)
const memoryQuizConfig = (room.gameConfig as any)?.["memory-quiz"] || {};
const memoryQuizConfig = (room.gameConfig as any)?.['memory-quiz'] || {}
initialState = validator.getInitialState({
selectedCount: memoryQuizConfig.selectedCount || 5,
displayTime: memoryQuizConfig.displayTime || 2.0,
selectedDifficulty: memoryQuizConfig.selectedDifficulty || "easy",
playMode: memoryQuizConfig.playMode || "cooperative",
});
selectedDifficulty: memoryQuizConfig.selectedDifficulty || 'easy',
playMode: memoryQuizConfig.playMode || 'cooperative',
})
// AFTER (type-safe, concise)
const config = getGameConfig(room.gameConfig, "memory-quiz");
initialState = validator.getInitialState(config);
const config = getGameConfig(room.gameConfig, 'memory-quiz')
initialState = validator.getInitialState(config)
```
**Usage in RoomMemoryQuizProvider.tsx:**
```typescript
// BEFORE (verbose, error-prone)
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any>;
const savedConfig = gameConfig?.["memory-quiz"];
const gameConfig = roomData?.gameConfig as Record<string, any>
const savedConfig = gameConfig?.['memory-quiz']
return {
...initialState,
selectedCount: savedConfig?.selectedCount ?? initialState.selectedCount,
displayTime: savedConfig?.displayTime ?? initialState.displayTime,
selectedDifficulty:
savedConfig?.selectedDifficulty ?? initialState.selectedDifficulty,
selectedDifficulty: savedConfig?.selectedDifficulty ?? initialState.selectedDifficulty,
playMode: savedConfig?.playMode ?? initialState.playMode,
};
}, [roomData?.gameConfig]);
}
}, [roomData?.gameConfig])
// AFTER (type-safe, concise)
const mergedInitialState = useMemo(() => {
const config = getGameConfig(roomData?.gameConfig, "memory-quiz");
const config = getGameConfig(roomData?.gameConfig, 'memory-quiz')
return {
...initialState,
...config, // Spread config directly - all settings included
};
}, [roomData?.gameConfig]);
...config, // Spread config directly - all settings included
}
}, [roomData?.gameConfig])
```
**Benefits:**
- No more manual property-by-property merging
- Type-safe
- Defaults handled automatically
@@ -209,7 +192,7 @@ const mergedInitialState = useMemo(() => {
```typescript
// src/lib/arcade/validation/MemoryQuizGameValidator.ts
import type { MemoryQuizGameConfig } from "@/lib/arcade/game-configs";
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
export class MemoryQuizGameValidator {
// BEFORE: Manual type definition
@@ -227,15 +210,14 @@ export class MemoryQuizGameValidator {
selectedCount: config.selectedCount,
displayTime: config.displayTime,
selectedDifficulty: config.selectedDifficulty,
playMode: config.playMode, // TypeScript ensures all fields are handled
playMode: config.playMode, // TypeScript ensures all fields are handled
// ...
};
}
}
}
```
**Benefits:**
- If you add a setting to `MemoryQuizGameConfig`, TypeScript forces you to handle it
- Impossible to forget a setting
- Impossible to use wrong type
@@ -281,46 +263,43 @@ If you add a new field to `MemoryQuizGameConfig`, TypeScript will error on `_exh
export function validateGameConfig(
gameName: GameName,
config: any,
config: any
): config is MatchingGameConfig | MemoryQuizGameConfig {
switch (gameName) {
case "matching":
case 'matching':
return (
typeof config.gameType === "string" &&
["abacus-numeral", "complement-pairs"].includes(config.gameType) &&
typeof config.difficulty === "number" &&
typeof config.gameType === 'string' &&
['abacus-numeral', 'complement-pairs'].includes(config.gameType) &&
typeof config.difficulty === 'number' &&
config.difficulty > 0 &&
typeof config.turnTimer === "number" &&
typeof config.turnTimer === 'number' &&
config.turnTimer > 0
);
)
case "memory-quiz":
case 'memory-quiz':
return (
[2, 5, 8, 12, 15].includes(config.selectedCount) &&
typeof config.displayTime === "number" &&
typeof config.displayTime === 'number' &&
config.displayTime > 0 &&
["beginner", "easy", "medium", "hard", "expert"].includes(
config.selectedDifficulty,
) &&
["cooperative", "competitive"].includes(config.playMode)
);
['beginner', 'easy', 'medium', 'hard', 'expert'].includes(config.selectedDifficulty) &&
['cooperative', 'competitive'].includes(config.playMode)
)
default:
return false;
return false
}
}
```
Use in settings API:
```typescript
// src/app/api/arcade/rooms/[roomId]/settings/route.ts
if (body.gameConfig !== undefined) {
if (!validateGameConfig(room.gameName, body.gameConfig[room.gameName])) {
return NextResponse.json({ error: "Invalid game config" }, { status: 400 });
return NextResponse.json({ error: 'Invalid game config' }, { status: 400 })
}
updateData.gameConfig = body.gameConfig;
updateData.gameConfig = body.gameConfig
}
```
@@ -338,7 +317,6 @@ All game configs are stored in a single JSON column in `arcade_rooms.gameConfig`
```
**Issues:**
- No schema validation
- Inefficient updates (read/parse/modify/serialize entire blob)
- Grows without bounds as more games added
@@ -353,37 +331,27 @@ Create `room_game_configs` table with one row per game per room:
```typescript
// src/db/schema/room-game-configs.ts
export const roomGameConfigs = sqliteTable(
"room_game_configs",
{
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
roomId: text("room_id")
.notNull()
.references(() => arcadeRooms.id, { onDelete: "cascade" }),
gameName: text("game_name", {
enum: ["matching", "memory-quiz", "complement-race"],
}).notNull(),
config: text("config", { mode: "json" }).notNull(), // Game-specific JSON
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
uniqueRoomGame: uniqueIndex("room_game_idx").on(
table.roomId,
table.gameName,
),
}),
);
export const roomGameConfigs = sqliteTable('room_game_configs', {
id: text('id').primaryKey().$defaultFn(() => createId()),
roomId: text('room_id')
.notNull()
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
gameName: text('game_name', {
enum: ['matching', 'memory-quiz', 'complement-race'],
}).notNull(),
config: text('config', { mode: 'json' }).notNull(), // Game-specific JSON
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
}, (table) => ({
uniqueRoomGame: uniqueIndex('room_game_idx').on(table.roomId, table.gameName),
}))
```
**Benefits:**
- ✅ Smaller rows (only configs for games that have been used)
- ✅ Easier updates (single row, not entire JSON blob)
- ✅ Can track updatedAt per game
@@ -391,7 +359,6 @@ export const roomGameConfigs = sqliteTable(
- ✅ Foundation for future audit trail
**Migration Strategy:**
1. Create new table
2. Migrate existing data from `arcade_rooms.gameConfig`
3. Update all config read/write code
@@ -403,7 +370,6 @@ See migration SQL below.
## Implementation Priority
### Phase 1: Schema Migration (HIGHEST PRIORITY)
1. **Create new table** - Add `room_game_configs` schema
2. **Create migration** - SQL to migrate existing data
3. **Update helper functions** - Adapt to new table structure
@@ -412,18 +378,15 @@ See migration SQL below.
6. **Drop old column** - Remove `gameConfig` from `arcade_rooms`
### Phase 2: Type Safety (HIGH)
1. **Create shared config types** (`game-configs.ts`) - Prevents type mismatches
2. **Create helper functions** (`game-config-helpers.ts`) - Now queries new table
3. **Update validators** to use shared types - Enforces consistency
### Phase 3: Compile-Time Safety (MEDIUM)
1. **Add exhaustiveness checking** - Catches missing fields at compile time
2. **Enforce validator config types** - Use shared types
### Phase 4: Runtime Safety (LOW)
1. **Add runtime validation** - Prevents invalid data from being saved
## Detailed Migration SQL
@@ -476,38 +439,32 @@ WHERE json_extract(game_config, '$."memory-quiz"') IS NOT NULL;
### Step-by-Step with Checkpoints
**Checkpoint 1: Schema & Migration**
1. Create `src/db/schema/room-game-configs.ts`
2. Export from `src/db/schema/index.ts`
3. Generate and apply migration
4. Verify data migrated correctly
**Checkpoint 2: Helper Functions**
1. Create shared config types in `src/lib/arcade/game-configs.ts`
2. Create helper functions in `src/lib/arcade/game-config-helpers.ts`
3. Add unit tests for helpers
**Checkpoint 3: Update Config Reads**
1. Update socket-server.ts to read from new table
2. Update RoomMemoryQuizProvider to read from new table
3. Update RoomMemoryPairsProvider to read from new table
4. Test: Load room and verify settings appear
**Checkpoint 4: Update Config Writes**
1. Update useRoomData.ts updateGameConfig to write to new table
2. Update settings API to write to new table
3. Test: Change settings and verify they persist
**Checkpoint 5: Update Validators**
1. Update validators to use shared config types
2. Test: All games work correctly
**Checkpoint 6: Cleanup**
1. Remove old gameConfig column references
2. Drop gameConfig column from arcade_rooms table
3. Final testing of all games

View File

@@ -1,623 +0,0 @@
# Cross-Game Stats Analysis & Universal Data Model
## Overview
This document analyzes ALL arcade games to ensure our `GameResult` type works universally.
## Games Analyzed
1.**Matching** (Memory Pairs)
2.**Complement Race** (Math race game)
3.**Memory Quiz** (Number memory game)
4.**Card Sorting** (Sort abacus cards)
5.**Rithmomachia** (Strategic board game)
6. 🔍 **YJS Demo** (Multiplayer demo - skipping for now)
---
## Per-Game Analysis
### 1. Matching (Memory Pairs)
**Game Type**: Memory/Pattern Matching
**Players**: 1-N (competitive multiplayer)
**How to Win**: Most pairs matched (multiplayer) OR complete all pairs (solo)
**Data Tracked**:
```typescript
{
scores: { [playerId]: matchCount }
moves: number
matchedPairs: number
totalPairs: number
gameTime: milliseconds
accuracy: percentage (matchedPairs / moves * 100)
grade: 'A+' | 'A' | 'B+' | ...
starRating: 1-5
}
```
**Winner Determination**:
- Solo: completed = won
- Multiplayer: highest score wins
**Fits GameResult?**
```typescript
{
gameType: 'matching',
duration: gameTime,
playerResults: [{
playerId,
won: isWinner,
score: matchCount,
accuracy: 0.0-1.0,
metrics: { moves, matchedPairs, difficulty }
}]
}
```
---
### 2. Complement Race
**Game Type**: Racing/Quiz hybrid
**Players**: 1-N (competitive race)
**How to Win**: Highest score OR reach finish line first (depending on mode)
**Data Tracked**:
```typescript
{
players: {
[playerId]: {
score: number
streak: number
bestStreak: number
correctAnswers: number
totalQuestions: number
position: 0-100% (for practice/survival)
deliveredPassengers: number (sprint mode)
}
}
gameTime: milliseconds
winner: playerId | null
leaderboard: [{ playerId, score, rank }]
}
```
**Winner Determination**:
- Practice/Survival: reach 100% position
- Sprint: highest score (delivered passengers)
**Fits GameResult?**
```typescript
{
gameType: 'complement-race',
duration: gameTime,
playerResults: [{
playerId,
won: winnerId === playerId,
score: player.score,
accuracy: player.correctAnswers / player.totalQuestions,
placement: leaderboard rank,
metrics: {
streak: player.bestStreak,
correctAnswers: player.correctAnswers,
totalQuestions: player.totalQuestions
}
}]
}
```
---
### 3. Memory Quiz
**Game Type**: Memory/Recall
**Players**: 1-N (cooperative OR competitive)
**How to Win**:
- Cooperative: team finds all numbers
- Competitive: most correct answers
**Data Tracked**:
```typescript
{
playerScores: {
[playerId]: { correct: number, incorrect: number }
}
foundNumbers: number[]
correctAnswers: number[]
selectedCount: 2 | 5 | 8 | 12 | 15
playMode: 'cooperative' | 'competitive'
gameTime: milliseconds
}
```
**Winner Determination**:
- Cooperative: ALL found = team wins
- Competitive: highest correct count wins
**Fits GameResult?****BUT needs special handling for cooperative**
```typescript
{
gameType: 'memory-quiz',
duration: gameTime,
playerResults: [{
playerId,
won: playMode === 'cooperative'
? foundAll // All players win or lose together
: hasHighestScore, // Individual winner
score: playerScores[playerId].correct,
accuracy: correct / (correct + incorrect),
metrics: {
correct: playerScores[playerId].correct,
incorrect: playerScores[playerId].incorrect,
difficulty: selectedCount
}
}],
metadata: {
playMode: 'cooperative' | 'competitive',
isTeamVictory: boolean // ← IMPORTANT for cooperative games
}
}
```
**NEW INSIGHT**: Cooperative games need special handling - all players share win/loss!
---
### 4. Card Sorting
**Game Type**: Sorting/Puzzle
**Players**: 1-N (solo, collaborative, competitive, relay)
**How to Win**:
- Solo: achieve high score (0-100)
- Collaborative: team achieves score
- Competitive: highest individual score
- Relay: TBD (not fully implemented)
**Data Tracked**:
```typescript
{
scoreBreakdown: {
finalScore: 0 - 100;
exactMatches: number;
lcsLength: number; // Longest common subsequence
inversions: number; // Out-of-order pairs
relativeOrderScore: 0 - 100;
exactPositionScore: 0 - 100;
inversionScore: 0 - 100;
elapsedTime: seconds;
}
gameMode: "solo" | "collaborative" | "competitive" | "relay";
}
```
**Winner Determination**:
- Solo/Collaborative: score > threshold (e.g., 70+)
- Competitive: highest score
**Fits GameResult?****Similar to Memory Quiz**
```typescript
{
gameType: 'card-sorting',
duration: elapsedTime * 1000,
playerResults: [{
playerId,
won: gameMode === 'collaborative'
? scoreBreakdown.finalScore >= 70 // Team threshold
: hasHighestScore,
score: scoreBreakdown.finalScore,
accuracy: scoreBreakdown.exactMatches / cardCount,
metrics: {
exactMatches: scoreBreakdown.exactMatches,
inversions: scoreBreakdown.inversions,
lcsLength: scoreBreakdown.lcsLength
}
}],
metadata: {
gameMode,
isTeamVictory: gameMode === 'collaborative'
}
}
```
---
### 5. Rithmomachia
**Game Type**: Strategic board game (2-player only)
**Players**: Exactly 2 (White vs Black)
**How to Win**: Multiple victory conditions (harmony, points, exhaustion, resignation)
**Data Tracked**:
```typescript
{
winner: 'W' | 'B' | null
winCondition: 'HARMONY' | 'EXHAUSTION' | 'RESIGNATION' | 'POINTS' | ...
capturedPieces: { W: Piece[], B: Piece[] }
pointsCaptured: { W: number, B: number }
history: MoveRecord[]
gameTime: milliseconds (computed from history)
}
```
**Winner Determination**:
- Specific win condition triggered
- No draws (or rare)
**Fits GameResult?****Needs win condition metadata**
```typescript
{
gameType: 'rithmomachia',
duration: gameTime,
playerResults: [
{
playerId: whitePlayerId,
won: winner === 'W',
score: capturedPieces.W.length, // or pointsCaptured.W
metrics: {
capturedPieces: capturedPieces.W.length,
points: pointsCaptured?.W || 0,
moves: history.filter(m => m.color === 'W').length
}
},
{
playerId: blackPlayerId,
won: winner === 'B',
score: capturedPieces.B.length,
metrics: {
capturedPieces: capturedPieces.B.length,
points: pointsCaptured?.B || 0,
moves: history.filter(m => m.color === 'B').length
}
}
],
metadata: {
winCondition: 'HARMONY' | 'POINTS' | ...
}
}
```
---
## Cross-Game Patterns Identified
### Pattern 1: Competitive (Most Common)
**Games**: Matching (multiplayer), Complement Race, Memory Quiz (competitive), Card Sorting (competitive)
**Characteristics**:
- Each player has their own score
- Winner = highest score
- Players track individually
**Stats to track per player**:
- games_played ++
- wins ++ (if winner)
- losses ++ (if not winner)
- best_time (if faster)
- highest_accuracy (if better)
---
### Pattern 2: Cooperative (Team-Based)
**Games**: Memory Quiz (cooperative), Card Sorting (collaborative)
**Characteristics**:
- All players share outcome
- Team wins or loses together
- Individual contributions still tracked
**Stats to track per player**:
- games_played ++
- wins ++ (if TEAM won) ← Key difference
- losses ++ (if TEAM lost)
- Individual metrics still tracked (correct answers, etc.)
**CRITICAL**: Check `metadata.isTeamVictory` to determine if all players get same win/loss!
---
### Pattern 3: Head-to-Head (Exactly 2 Players)
**Games**: Rithmomachia
**Characteristics**:
- Always 2 players
- One wins, one loses (rare draws)
- Different win conditions
**Stats to track per player**:
- games_played ++
- wins ++ (winner only)
- losses ++ (loser only)
- Game-specific metrics (captures, harmonies)
---
### Pattern 4: Solo Completion
**Games**: Matching (solo), Complement Race (practice), Memory Quiz (solo), Card Sorting (solo)
**Characteristics**:
- Single player
- Win = completion or threshold
- Compete against self/time
**Stats to track**:
- games_played ++
- wins ++ (if completed/threshold met)
- losses ++ (if failed/gave up)
- best_time, highest_accuracy
---
## Refined Universal Data Model
### GameResult Type (UPDATED)
```typescript
export interface GameResult {
// Game identification
gameType: string; // e.g., "matching", "complement-race", etc.
// Player results (supports 1-N players)
playerResults: PlayerGameResult[];
// Timing
completedAt: number; // timestamp
duration: number; // milliseconds
// Optional game-specific data
metadata?: {
// For cooperative games
isTeamVictory?: boolean; // ← NEW: all players share win/loss
// For specific win conditions
winCondition?: string; // e.g., "HARMONY", "POINTS", "TIMEOUT"
// For game modes
gameMode?: string; // e.g., "solo", "competitive", "cooperative"
// Any other game-specific info
[key: string]: unknown;
};
}
export interface PlayerGameResult {
playerId: string;
// Outcome
won: boolean; // For cooperative: all players same value
placement?: number; // 1st, 2nd, 3rd (for competitive with >2 players)
// Performance
score?: number;
accuracy?: number; // 0.0 - 1.0
completionTime?: number; // milliseconds (player-specific time)
// Game-specific metrics (optional, stored as JSON in DB)
metrics?: {
// Matching
moves?: number;
matchedPairs?: number;
difficulty?: number;
// Complement Race
streak?: number;
correctAnswers?: number;
totalQuestions?: number;
// Memory Quiz
correct?: number;
incorrect?: number;
// Card Sorting
exactMatches?: number;
inversions?: number;
lcsLength?: number;
// Rithmomachia
capturedPieces?: number;
points?: number;
// Extensible for future games
[key: string]: unknown;
};
}
```
---
## Stats Recording Logic (UPDATED)
### For Each Player in GameResult
```typescript
// Fetch player stats
const stats = await getPlayerStats(playerId);
// Always increment
stats.gamesPlayed++;
// Handle wins/losses based on game type
if (gameResult.metadata?.isTeamVictory !== undefined) {
// COOPERATIVE: All players share outcome
if (playerResult.won) {
stats.totalWins++;
} else {
stats.totalLosses++;
}
} else {
// COMPETITIVE/SOLO: Individual outcome
if (playerResult.won) {
stats.totalWins++;
} else {
stats.totalLosses++;
}
}
// Update performance metrics
if (
playerResult.completionTime &&
(!stats.bestTime || playerResult.completionTime < stats.bestTime)
) {
stats.bestTime = playerResult.completionTime;
}
if (playerResult.accuracy && playerResult.accuracy > stats.highestAccuracy) {
stats.highestAccuracy = playerResult.accuracy;
}
// Update per-game stats (JSON)
stats.gameStats[gameResult.gameType] = {
gamesPlayed: (stats.gameStats[gameResult.gameType]?.gamesPlayed || 0) + 1,
wins:
(stats.gameStats[gameResult.gameType]?.wins || 0) +
(playerResult.won ? 1 : 0),
// ... other game-specific aggregates
};
// Update favorite game type (most played)
stats.favoriteGameType = getMostPlayedGame(stats.gameStats);
// Update timestamps
stats.lastPlayedAt = gameResult.completedAt;
stats.updatedAt = Date.now();
```
---
## Database Schema (CONFIRMED)
No changes needed from original design! The `metrics` JSON field handles game-specific data perfectly.
```sql
CREATE TABLE player_stats (
player_id TEXT PRIMARY KEY,
-- Aggregates
games_played INTEGER NOT NULL DEFAULT 0,
total_wins INTEGER NOT NULL DEFAULT 0,
total_losses INTEGER NOT NULL DEFAULT 0,
-- Performance
best_time INTEGER,
highest_accuracy REAL NOT NULL DEFAULT 0,
-- Per-game breakdown (JSON)
game_stats TEXT NOT NULL DEFAULT '{}',
-- Meta
favorite_game_type TEXT,
last_played_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
```
---
## Key Insights & Design Decisions
### 1. Cooperative Games Need Special Flag
**Problem**: Memory Quiz (cooperative) and Card Sorting (collaborative) - all players share win/loss.
**Solution**: Add `metadata.isTeamVictory: boolean` to `GameResult`. When `true`, recording logic gives ALL players the same win/loss.
### 2. Flexible Metrics Field
**Problem**: Each game tracks different metrics (moves, streak, inversions, etc.).
**Solution**: `PlayerGameResult.metrics` is an open object. Store game-specific data here, saved as JSON in DB.
### 3. Placement for Tournaments
**Problem**: 3+ player games need to track ranking (1st, 2nd, 3rd).
**Solution**: `PlayerGameResult.placement` field. Useful for leaderboards.
### 4. Win Conditions Matter
**Problem**: Rithmomachia has multiple win conditions (harmony, points, etc.).
**Solution**: `metadata.winCondition` stores how the game was won. Useful for achievements/stats breakdown.
### 5. Score is Optional
**Problem**: Not all games have scores (e.g., Rithmomachia can win by harmony without points enabled).
**Solution**: Make `score` optional. Use `won` as primary outcome indicator.
---
## Testing Matrix
### Scenarios to Test
| Game | Mode | Players | Expected Outcome |
| --------------- | ------------- | ------- | ----------------------------------- |
| Matching | Solo | 1 | Player wins if completed |
| Matching | Competitive | 2+ | Winner = highest score, others lose |
| Complement Race | Sprint | 2+ | Winner = highest score |
| Memory Quiz | Cooperative | 2+ | ALL win or ALL lose (team) |
| Memory Quiz | Competitive | 2+ | Winner = most correct |
| Card Sorting | Solo | 1 | Win if score >= 70 |
| Card Sorting | Collaborative | 2+ | ALL win or ALL lose (team) |
| Card Sorting | Competitive | 2+ | Winner = highest score |
| Rithmomachia | PvP | 2 | One wins (by condition), one loses |
---
## Conclusion
**Universal `GameResult` type CONFIRMED to work for all games**
**Key Requirements**:
1. Support 1-N players (flexible array)
2. Support cooperative games (isTeamVictory flag)
3. Support game-specific metrics (open metrics object)
4. Support multiple win conditions (winCondition metadata)
5. Track both individual AND team performance
**Next Steps**:
1. Update `.claude/PER_PLAYER_STATS_ARCHITECTURE.md` with refined types
2. Implement database schema
3. Build API endpoints
4. Create React hooks
5. Integrate with each game (starting with Matching)
---
**Status**: ✅ Complete cross-game analysis
**Result**: GameResult type is universal and robust
**Date**: 2025-01-03

View File

@@ -3,7 +3,6 @@
## Problem
Previously, each game manually specified `color`, `gradient`, and `borderColor` in their manifest. This led to:
- Inconsistent appearance across game cards
- No guidance on what colors/gradients to use
- Easy to choose saturated colors that don't match the pastel style
@@ -20,29 +19,28 @@ All games now use predefined color themes that ensure consistent, professional a
### 1. Import from the Game SDK
```typescript
import { defineGame, getGameTheme } from "@/lib/arcade/game-sdk";
import type { GameManifest } from "@/lib/arcade/game-sdk";
import { defineGame, getGameTheme } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
```
### 2. Use the Theme Spread Operator
```typescript
const manifest: GameManifest = {
name: "my-game",
displayName: "My Awesome Game",
icon: "🎮",
description: "A fun game",
longDescription: "More details...",
name: 'my-game',
displayName: 'My Awesome Game',
icon: '🎮',
description: 'A fun game',
longDescription: 'More details...',
maxPlayers: 4,
difficulty: "Intermediate",
chips: ["🎯 Feature 1", "⚡ Feature 2"],
...getGameTheme("blue"), // ← Just add this!
difficulty: 'Intermediate',
chips: ['🎯 Feature 1', '⚡ Feature 2'],
...getGameTheme('blue'), // ← Just add this!
available: true,
};
}
```
That's it! The theme automatically provides:
- `color: 'blue'`
- `gradient: 'linear-gradient(135deg, #dbeafe, #bfdbfe)'`
- `borderColor: 'blue.200'`
@@ -51,18 +49,18 @@ That's it! The theme automatically provides:
All themes use Panda CSS's 100-200 color range for soft pastel appearance:
| Theme | Color Range | Use Case |
| -------- | ------------------------ | ------------------------- |
| `blue` | blue-100 to blue-200 | Memory, puzzle games |
| `purple` | purple-100 to purple-200 | Strategic, battle games |
| `green` | green-100 to green-200 | Growth, achievement games |
| `teal` | teal-100 to teal-200 | Creative, sorting games |
| `indigo` | indigo-100 to indigo-200 | Deep thinking games |
| `pink` | pink-100 to pink-200 | Fun, casual games |
| `orange` | orange-100 to orange-200 | Speed, energy games |
| `yellow` | yellow-100 to yellow-200 | Bright, happy games |
| `red` | red-100 to red-200 | Competition, challenge |
| `gray` | gray-100 to gray-200 | Neutral games |
| Theme | Color Range | Use Case |
|-------|-------------|----------|
| `blue` | blue-100 to blue-200 | Memory, puzzle games |
| `purple` | purple-100 to purple-200 | Strategic, battle games |
| `green` | green-100 to green-200 | Growth, achievement games |
| `teal` | teal-100 to teal-200 | Creative, sorting games |
| `indigo` | indigo-100 to indigo-200 | Deep thinking games |
| `pink` | pink-100 to pink-200 | Fun, casual games |
| `orange` | orange-100 to orange-200 | Speed, energy games |
| `yellow` | yellow-100 to yellow-200 | Bright, happy games |
| `red` | red-100 to red-200 | Competition, challenge |
| `gray` | gray-100 to gray-200 | Neutral games |
## Examples
@@ -95,11 +93,11 @@ All themes use Panda CSS's 100-200 color range for soft pastel appearance:
If you need to inspect or customize a theme:
```typescript
import { GAME_THEMES } from "@/lib/arcade/game-sdk";
import type { GameTheme } from "@/lib/arcade/game-sdk";
import { GAME_THEMES } from '@/lib/arcade/game-sdk'
import type { GameTheme } from '@/lib/arcade/game-sdk'
// Access a specific theme
const blueTheme: GameTheme = GAME_THEMES.blue;
const blueTheme: GameTheme = GAME_THEMES.blue
// Use it
const manifest: GameManifest = {
@@ -107,9 +105,9 @@ const manifest: GameManifest = {
...blueTheme,
// Or customize:
color: blueTheme.color,
gradient: "linear-gradient(135deg, #custom, #values)", // override
gradient: 'linear-gradient(135deg, #custom, #values)', // override
borderColor: blueTheme.borderColor,
};
}
```
## Adding New Themes
@@ -120,17 +118,16 @@ To add a new theme, edit `/src/lib/arcade/game-themes.ts`:
export const GAME_THEMES = {
// ... existing themes
mycolor: {
color: "mycolor",
gradient: "linear-gradient(135deg, #lighter, #darker)", // Use Panda CSS 100-200 range
borderColor: "mycolor.200",
color: 'mycolor',
gradient: 'linear-gradient(135deg, #lighter, #darker)', // Use Panda CSS 100-200 range
borderColor: 'mycolor.200',
},
} as const satisfies Record<string, GameTheme>;
} as const satisfies Record<string, GameTheme>
```
Then update the TypeScript type:
```typescript
export type GameThemeName = keyof typeof GAME_THEMES;
export type GameThemeName = keyof typeof GAME_THEMES
```
## Migration Checklist
@@ -145,7 +142,6 @@ When creating a new game:
## Summary
**Old way** (error-prone, inconsistent):
```typescript
color: 'teal',
gradient: 'linear-gradient(135deg, #99f6e4, #5eead4)', // Too saturated!
@@ -153,7 +149,6 @@ borderColor: 'teal.200',
```
**New way** (simple, consistent):
```typescript
...getGameTheme('teal')
```

View File

@@ -1,504 +0,0 @@
# Google Classroom Integration Setup Guide
**Goal:** Set up Google Classroom API integration using mostly CLI commands, minimizing web console interaction.
**Time Required:** 15-20 minutes
**Cost:** $0 (free for educational use)
---
## Prerequisites
**gcloud CLI installed** (already installed at `/opt/homebrew/bin/gcloud`)
**Valid Google account**
- **Billing account** (required by Google, but FREE for Classroom API)
---
## Quick Start (TL;DR)
```bash
# Run the automated setup script
./scripts/setup-google-classroom.sh
```
The script will:
1. Authenticate with your Google account
2. Create a GCP project
3. Enable Classroom & People APIs
4. Guide you through OAuth setup (2 web console steps)
5. Configure your `.env.local` file
**Note:** Steps 6 & 7 still require web console (Google doesn't provide CLI for OAuth consent screen), but the script opens the pages for you and provides exact instructions.
---
## What the Script Does (Step by Step)
### 1. Authentication ✅ Fully Automated
```bash
gcloud auth login
```
Opens browser, you log in with Google, done.
### 2. Create GCP Project ✅ Fully Automated
```bash
PROJECT_ID="soroban-abacus-$(date +%s)" # Unique ID with timestamp
gcloud projects create "$PROJECT_ID" --name="Soroban Abacus Flashcards"
gcloud config set project "$PROJECT_ID"
```
### 3. Link Billing Account ✅ Mostly Automated
```bash
# List your billing accounts
gcloud billing accounts list
# Link to project
gcloud billing projects link "$PROJECT_ID" --billing-account="BILLING_ACCOUNT_ID"
```
**Why billing is required:**
- Google requires billing for API access (even free APIs!)
- Classroom API is **FREE** with no usage charges
- You won't be charged unless you enable paid services
**If you don't have a billing account:**
- Script will prompt you to create one at: https://console.cloud.google.com/billing
- It's quick: just add payment method (won't be charged)
- Press Enter in terminal after creation
### 4. Enable APIs ✅ Fully Automated
```bash
gcloud services enable classroom.googleapis.com
gcloud services enable people.googleapis.com
```
Takes 1-2 minutes to propagate.
### 5. Create OAuth Credentials ⚠️ Requires Web Console
**Why CLI doesn't work:**
Google doesn't provide `gcloud` commands for creating OAuth clients. You need the web console.
**What the script does:**
- Opens: https://console.cloud.google.com/apis/credentials?project=YOUR_PROJECT
- Provides exact instructions (copy-paste ready)
**Manual steps (takes 2 minutes):**
1. Click "**Create Credentials**" → "**OAuth client ID**"
2. Application type: **"Web application"**
3. Name: **"Soroban Abacus Web"**
4. **Authorized JavaScript origins:**
```
http://localhost:3000
https://abaci.one
```
5. **Authorized redirect URIs:**
```
http://localhost:3000/api/auth/callback/google
https://abaci.one/api/auth/callback/google
```
6. Click **"Create"**
7. **Copy** the Client ID and Client Secret (you'll paste into terminal)
### 6. Configure OAuth Consent Screen ⚠️ Requires Web Console
**Why CLI doesn't work:**
OAuth consent screen configuration is web-only.
**What the script does:**
- Opens: https://console.cloud.google.com/apis/credentials/consent?project=YOUR_PROJECT
- Provides step-by-step instructions
**Manual steps (takes 3 minutes):**
**Screen 1: OAuth consent screen**
- User Type: **"External"** (unless you have Google Workspace)
- Click "**Create**"
**Screen 2: App information**
- App name: **"Soroban Abacus Flashcards"**
- User support email: **Your email**
- App logo: (optional)
- App domain: (optional, can add later)
- Developer contact: **Your email**
- Click "**Save and Continue**"
**Screen 3: Scopes**
- Click "**Add or Remove Scopes**"
- Filter/search for these scopes and check them:
- ✅ `.../auth/userinfo.email` (See your primary Google Account email)
- ✅ `.../auth/userinfo.profile` (See your personal info)
- ✅ `.../auth/classroom.courses.readonly` (View courses)
- ✅ `.../auth/classroom.rosters.readonly` (View class rosters)
- Click "**Update**"
- Click "**Save and Continue**"
**Screen 4: Test users**
- Click "**Add Users**"
- Add your email address (for testing)
- Click "**Save and Continue**"
**Screen 5: Summary**
- Review and click "**Back to Dashboard**"
Done! ✅
### 7. Save Credentials to .env.local ✅ Fully Automated
Script prompts you for:
- Client ID (paste from step 5)
- Client Secret (paste from step 5)
Then automatically adds to `.env.local`:
```bash
# Google OAuth (Generated by setup-google-classroom.sh)
GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="GOCSPX-your-secret"
```
---
## After Running the Script
### Verify Setup
```bash
# Check project configuration
gcloud config get-value project
# List enabled APIs
gcloud services list --enabled
# Check Classroom API is enabled
gcloud services list --enabled | grep classroom
```
Expected output:
```
classroom.googleapis.com Google Classroom API
```
### Test API Access
```bash
# Get an access token
gcloud auth application-default login
gcloud auth application-default print-access-token
# Test Classroom API (replace TOKEN)
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://classroom.googleapis.com/v1/courses
```
Expected response (if you have no courses yet):
```json
{}
```
---
## NextAuth Configuration
Now that you have credentials, add Google provider to NextAuth:
### 1. Check Current NextAuth Config
```bash
cat src/app/api/auth/[...nextauth]/route.ts
```
### 2. Add Google Provider
Add to your NextAuth providers array:
```typescript
import GoogleProvider from "next-auth/providers/google";
export const authOptions: NextAuthOptions = {
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
authorization: {
params: {
scope: [
"openid",
"email",
"profile",
"https://www.googleapis.com/auth/classroom.courses.readonly",
"https://www.googleapis.com/auth/classroom.rosters.readonly",
].join(" "),
prompt: "consent",
access_type: "offline",
response_type: "code",
},
},
}),
// ... your existing providers
],
// ... rest of config
};
```
### 3. Test Login
```bash
# Start dev server
npm run dev
# Open browser
open http://localhost:3000
```
Click "Sign in with Google" and verify:
- ✅ OAuth consent screen appears
- ✅ Shows requested permissions
- ✅ Successfully logs in
- ✅ User profile is created
---
## Troubleshooting
### "Billing account required"
**Problem:** Can't enable APIs without billing
**Solution:** Create billing account at https://console.cloud.google.com/billing
- Won't be charged for Classroom API (it's free)
- Just need payment method on file
### "Error 401: deleted_client"
**Problem:** OAuth client was deleted or not created properly
**Solution:** Re-run OAuth client creation (step 5)
```bash
open "https://console.cloud.google.com/apis/credentials?project=$(gcloud config get-value project)"
```
### "Error 403: Access Not Configured"
**Problem:** APIs not enabled yet (takes 1-2 min to propagate)
**Solution:** Wait 2 minutes, then verify:
```bash
gcloud services list --enabled | grep classroom
```
### "Invalid redirect URI"
**Problem:** Redirect URI doesn't match OAuth client config
**Solution:** Check that these URIs are in your OAuth client:
- http://localhost:3000/api/auth/callback/google
- https://abaci.one/api/auth/callback/google
### "App is not verified"
**Problem:** OAuth consent screen in "Testing" mode
**Solution:** This is **normal** for development!
- Click "Advanced" → "Go to [app name] (unsafe)"
- Only affects external test users
- For production, submit for verification (takes 1-2 weeks)
---
## CLI Reference
### Project Management
```bash
# List all your projects
gcloud projects list
# Switch project
gcloud config set project PROJECT_ID
# Delete project (if needed)
gcloud projects delete PROJECT_ID
```
### API Management
```bash
# List enabled APIs
gcloud services list --enabled
# Enable an API
gcloud services enable APINAME.googleapis.com
# Disable an API
gcloud services disable APINAME.googleapis.com
# Check quota
gcloud services quota describe classroom.googleapis.com
```
### OAuth Management
```bash
# List OAuth clients (requires REST API)
PROJECT_ID=$(gcloud config get-value project)
ACCESS_TOKEN=$(gcloud auth application-default print-access-token)
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://oauth2.googleapis.com/v1/projects/$PROJECT_ID/oauthClients"
```
### Billing
```bash
# List billing accounts
gcloud billing accounts list
# Link billing to project
gcloud billing projects link PROJECT_ID --billing-account=ACCOUNT_ID
# Check project billing status
gcloud billing projects describe PROJECT_ID
```
---
## What You Can Do From CLI (Summary)
✅ **Fully Automated:**
- Authenticate with Google
- Create GCP project
- Enable APIs
- Link billing account
- Configure environment variables
⚠️ **Requires Web Console (2-5 minutes):**
- Create OAuth client (2 min)
- Configure OAuth consent screen (3 min)
**Why web console required:**
Google doesn't provide CLI for these security-sensitive operations. But the script:
- Opens the exact pages for you
- Provides step-by-step instructions
- Makes it as painless as possible
---
## Cost Breakdown
| Item | Cost |
| --------------------------- | ----------------- |
| GCP project | $0 |
| Google Classroom API | $0 (free forever) |
| Google People API | $0 (free forever) |
| Billing account requirement | $0 (no charges) |
| **Total** | **$0** |
**Note:** You need to add a payment method for billing account, but Google Classroom API is completely free with no usage limits.
---
## Next Steps After Setup
1. ✅ Run the setup script: `./scripts/setup-google-classroom.sh`
2. ✅ Add Google provider to NextAuth
3. ✅ Test "Sign in with Google"
4. 📝 Implement class import feature (Phase 2 of roadmap)
5. 📝 Build teacher dashboard
6. 📝 Add assignment integration
Refer to `.claude/PLATFORM_INTEGRATION_ROADMAP.md` for full implementation timeline.
---
## Security Best Practices
### Protect Your Secrets
```bash
# Check .env.local is in .gitignore
cat .gitignore | grep .env.local
```
Should see:
```
.env*.local
```
### Rotate Credentials Periodically
```bash
# Open credentials page
PROJECT_ID=$(gcloud config get-value project)
open "https://console.cloud.google.com/apis/credentials?project=$PROJECT_ID"
# Delete old client, create new one
# Update .env.local with new credentials
```
### Use Different Credentials for Dev/Prod
**Development:**
- OAuth client: `http://localhost:3000/api/auth/callback/google`
- Test users only
**Production:**
- OAuth client: `https://abaci.one/api/auth/callback/google`
- Verified app (submit for review)
---
## Resources
**Official Documentation:**
- GCP CLI: https://cloud.google.com/sdk/gcloud
- Classroom API: https://developers.google.com/classroom
- OAuth 2.0: https://developers.google.com/identity/protocols/oauth2
**Script Location:**
- `scripts/setup-google-classroom.sh`
**Configuration Files:**
- `.env.local` (credentials)
- `src/app/api/auth/[...nextauth]/route.ts` (NextAuth config)
---
**Ready to run?**
```bash
./scripts/setup-google-classroom.sh
```
Good luck! 🚀

View File

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

View File

@@ -48,7 +48,6 @@ WHERE game_config IS NOT NULL
```
**Results:**
- 5991 matching game configs migrated
- 9 memory-quiz game configs migrated
- Total: 6000 configs
@@ -56,12 +55,10 @@ WHERE game_config IS NOT NULL
## Old vs New Schema
**Old Schema:**
- `arcade_rooms.game_config` (TEXT/JSON) - stored config for currently selected game only
- Config was lost when switching games
**New Schema:**
- `room_game_configs` table - one row per game per room
- Unique constraint on (room_id, game_name)
- Configs persist when switching between games
@@ -87,7 +84,6 @@ sqlite3 data/sqlite.db "SELECT game_name, COUNT(*) FROM room_game_configs GROUP
## Related Files
This migration supports the refactoring documented in:
- `.claude/GAME_SETTINGS_PERSISTENCE.md` - Architecture documentation
- `src/lib/arcade/game-configs.ts` - Shared config types
- `src/lib/arcade/game-config-helpers.ts` - Database access helpers

View File

@@ -1,299 +0,0 @@
# Matching Game Stats Integration Guide
## Quick Reference
**Files to modify**: `src/arcade-games/matching/components/ResultsPhase.tsx`
**What we're adding**: Call `useRecordGameResult()` when game completes to save per-player stats.
## Current State Analysis
### ResultsPhase.tsx (lines 9-29)
Already has all the data we need:
```typescript
const { state, resetGame, activePlayers, gameMode, exitSession } =
useMatching();
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode();
const gameTime =
state.gameEndTime && state.gameStartTime
? state.gameEndTime - state.gameStartTime
: 0;
const analysis = getPerformanceAnalysis(state);
const multiplayerResult =
gameMode === "multiplayer"
? getMultiplayerWinner(state, activePlayers)
: null;
```
**Available data:**
-`state.scores` - scores by player ID
-`state.gameStartTime`, `state.gameEndTime` - timing
-`state.matchedPairs`, `state.totalPairs` - completion
-`state.moves` - total moves
-`activePlayers` - array of player IDs
-`multiplayerResult.winners` - who won
-`analysis.statistics.accuracy` - accuracy percentage
## Implementation Steps
### Step 1: Add state flag to prevent duplicate recording
Add `recorded: boolean` to `MatchingState` type:
```typescript
// src/arcade-games/matching/types.ts (add to MatchingState interface)
export interface MatchingState extends GameState {
// ... existing fields ...
// Stats recording
recorded?: boolean; // ← ADD THIS
}
```
### Step 2: Import the hook in ResultsPhase.tsx
```typescript
// At top of src/arcade-games/matching/components/ResultsPhase.tsx
import { useEffect } from "react"; // ← ADD if not present
import { useRecordGameResult } from "@/hooks/useRecordGameResult";
import type { GameResult } from "@/lib/arcade/stats/types";
```
### Step 3: Call the hook
```typescript
// Inside ResultsPhase component, after existing hooks
export function ResultsPhase() {
const router = useRouter()
const { state, resetGame, activePlayers, gameMode, exitSession } = useMatching()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
// ← ADD THIS
const { mutate: recordGame, isPending: isRecording } = useRecordGameResult()
// ... existing code ...
```
### Step 4: Record game result on mount
Add this useEffect after the hook declarations:
```typescript
// Record game result once when entering results phase
useEffect(() => {
// Only record if we haven't already
if (state.phase === "results" && !state.recorded && !isRecording) {
const gameTime =
state.gameEndTime && state.gameStartTime
? state.gameEndTime - state.gameStartTime
: 0;
const analysis = getPerformanceAnalysis(state);
const multiplayerResult =
gameMode === "multiplayer"
? getMultiplayerWinner(state, activePlayers)
: null;
// Build GameResult
const gameResult: GameResult = {
gameType:
state.gameType === "abacus-numeral"
? "matching-abacus"
: "matching-complements",
completedAt: state.gameEndTime || Date.now(),
duration: gameTime,
playerResults: activePlayers.map((playerId) => {
const score = state.scores[playerId] || 0;
const won = multiplayerResult
? multiplayerResult.winners.includes(playerId)
: state.matchedPairs === state.totalPairs; // Solo = completed
// In multiplayer, calculate per-player accuracy from their score
// In single player, use overall accuracy
const playerAccuracy =
gameMode === "multiplayer"
? score / state.totalPairs // Their score as fraction of total pairs
: analysis.statistics.accuracy / 100; // Convert percentage to 0-1
return {
playerId,
won,
score,
accuracy: playerAccuracy,
completionTime: gameTime,
metrics: {
moves: state.moves,
matchedPairs: state.matchedPairs,
difficulty: state.difficulty,
},
};
}),
metadata: {
gameType: state.gameType,
difficulty: state.difficulty,
grade: analysis.grade,
starRating: analysis.starRating,
},
};
// Record to database
recordGame(gameResult, {
onSuccess: (updates) => {
console.log("✅ Stats recorded:", updates);
// Mark as recorded to prevent duplicate saves
// Note: This assumes Provider has a way to update state.recorded
// We'll need to add an action for this
},
onError: (error) => {
console.error("❌ Failed to record stats:", error);
},
});
}
}, [state.phase, state.recorded, isRecording /* ... deps */]);
```
### Step 5: Add loading state UI (optional)
Show a subtle loading indicator while recording:
```typescript
// At the top of the return statement in ResultsPhase
if (isRecording) {
return (
<div className={css({
textAlign: 'center',
padding: '20px',
})}>
<p>Saving results...</p>
</div>
)
}
```
Or keep it subtle and just disable buttons:
```typescript
// On the "Play Again" button
<button
disabled={isRecording}
className={css({
// ... styles ...
opacity: isRecording ? 0.5 : 1,
cursor: isRecording ? 'not-allowed' : 'pointer',
})}
onClick={resetGame}
>
{isRecording ? '💾 Saving...' : '🎮 Play Again'}
</button>
```
## Provider Changes Needed
The Provider needs an action to mark the game as recorded:
```typescript
// src/arcade-games/matching/Provider.tsx
// Add to the context type
export interface MatchingContextType {
// ... existing ...
markAsRecorded: () => void; // ← ADD THIS
}
// Add to the reducer or state update logic
const markAsRecorded = useCallback(() => {
setState((prev) => ({ ...prev, recorded: true }));
}, []);
// Add to the context value
const contextValue: MatchingContextType = {
// ... existing ...
markAsRecorded,
};
```
Then in ResultsPhase useEffect:
```typescript
onSuccess: (updates) => {
console.log("✅ Stats recorded:", updates);
markAsRecorded(); // ← Use this instead
};
```
## Testing Checklist
### Solo Game
- [ ] Play a game to completion
- [ ] Check console for "✅ Stats recorded"
- [ ] Refresh page
- [ ] Go to `/games` page
- [ ] Verify player's gamesPlayed incremented
- [ ] Verify player's totalWins incremented (if completed)
### Multiplayer Game
- [ ] Activate 2+ players
- [ ] Play a game to completion
- [ ] Check console for stats for ALL players
- [ ] Go to `/games` page
- [ ] Verify each player's stats updated independently
- [ ] Winner should have +1 win
- [ ] All players should have +1 games played
### Edge Cases
- [ ] Incomplete game (exit early) - should NOT record
- [ ] Play again from results - should NOT duplicate record
- [ ] Network error during save - should show error, not mark as recorded
## Common Issues
### Issue: Stats recorded multiple times
**Cause**: useEffect dependency array missing or incorrect
**Fix**: Ensure `state.recorded` is in deps and checked in condition
### Issue: Can't read property 'id' of undefined
**Cause**: Player not found in playerMap
**Fix**: Add null checks when mapping activePlayers
### Issue: Accuracy is always 100% or 0%
**Cause**: Wrong calculation or unit (percentage vs decimal)
**Fix**: Ensure accuracy is 0.0 - 1.0, not 0-100
### Issue: Single player never "wins"
**Cause**: Wrong win condition for solo mode
**Fix**: Solo player wins if they complete all pairs (`state.matchedPairs === state.totalPairs`)
## Next Steps After Integration
1. ✅ Verify stats save correctly
2. ✅ Update `/games` page to fetch and display per-player stats
3. ✅ Test with different game modes and difficulties
4. 🔄 Repeat this pattern for other arcade games
5. 📊 Add stats visualization/charts (future)
---
**Status**: Ready for implementation
**Blocked by**:
- Database schema (player_stats table)
- API endpoints (/api/player-stats/record-game)
- React hooks (useRecordGameResult)

View File

@@ -1,707 +0,0 @@
# Intelligent Merge Conflict Resolution with diff3
## Overview
This document describes best practices for intelligently resolving Git merge conflicts using diff3-style conflict markers, which show the common ancestor to provide crucial context about what changed on each side.
## What is diff3?
**diff3** is a 3-way merge conflict style that shows:
1. **OURS** (HEAD/current branch changes)
2. **BASE** (common ancestor/original code)
3. **THEIRS** (incoming branch changes)
Standard Git conflicts only show OURS vs THEIRS, making it impossible to determine which side added or removed what. With diff3, you can see **what changed** on each side relative to the base.
## Conflict Marker Format
```
<<<<<<< HEAD (or branch name)
our changes - what we did to the base
||||||| base (or commit hash)
original content - the common ancestor
=======
their changes - what they did to the base
>>>>>>> branch-name (or commit hash)
```
## Why diff3 is Superior
### Without diff3 (standard merge):
```
<<<<<<< HEAD
function calculate(a, b) {
return a + b + 10;
}
=======
function calculate(x, y) {
return x + y;
}
>>>>>>> feature-branch
```
**Problem:** Can't tell if:
- We added `+ 10` or they removed it?
- We renamed params or they renamed them?
- Both changes are intentional or redundant?
### With diff3:
```
<<<<<<< HEAD
function calculate(a, b) {
return a + b + 10;
}
||||||| base
function calculate(a, b) {
return a + b;
}
=======
function calculate(x, y) {
return x + y;
}
>>>>>>> feature-branch
```
**Clear insights:**
- **OURS**: Added `+ 10` (kept param names)
- **BASE**: Original had `a + b`
- **THEIRS**: Renamed params to `x, y`
- **Resolution**: Combine both changes: `return x + y + 10;`
## Intelligent Resolution Strategy
### Step 1: Compare Each Side to Base
For each conflict:
1. **OURS vs BASE**: What did we change?
2. **THEIRS vs BASE**: What did they change?
3. **Classify the conflict type** (see below)
### Step 2: Classify Conflict Type
| Conflict Type | Description | Resolution Strategy |
| ----------------------- | -------------------------------------- | ------------------------------------------------------------- |
| **Compatible** | Changes are to different parts/aspects | Keep both changes |
| **Redundant** | Same intent, different implementation | Choose the better implementation or merge carefully |
| **Conflicting** | Incompatible changes to same logic | Understand intent, combine if possible, or choose one |
| **Delete vs Modify** | One side deleted, other modified | Decide if modification is still relevant without deleted code |
| **Rename vs Reference** | One renamed, other added references | Update references to new name |
### Step 3: Resolution Patterns
#### Pattern 1: Independent Changes (Compatible)
```
<<<<<<< HEAD
function process(data) {
validate(data); // We added validation
return transform(data);
}
||||||| base
function process(data) {
return transform(data);
}
=======
function process(data) {
return transform(data).toUpperCase(); // They added formatting
}
>>>>>>> feature
```
**Analysis:**
- OURS: Added validation call (new line)
- THEIRS: Added `.toUpperCase()` to return (modified existing line)
- Both changes are independent
**Resolution:**
```javascript
function process(data) {
validate(data); // Keep our validation
return transform(data).toUpperCase(); // Keep their formatting
}
```
#### Pattern 2: Same Intent, Different Implementation (Redundant)
```
<<<<<<< HEAD
if (!data || data.length === 0) {
throw new Error('Data required');
}
||||||| base
// no validation
=======
if (data.length === 0) {
throw new Error('Data required');
}
>>>>>>> feature
```
**Analysis:**
- OURS: Added null check + length check
- THEIRS: Added length check only
- Same intent (validation), but OURS is more robust
**Resolution:**
```javascript
// Choose the more robust implementation (OURS)
if (!data || data.length === 0) {
throw new Error("Data required");
}
```
#### Pattern 3: Conflicting Logic
```
<<<<<<< HEAD
const result = calculate(a, b, mode === 'strict');
||||||| base
const result = calculate(a, b);
=======
const result = await calculateAsync(a, b);
>>>>>>> feature
```
**Analysis:**
- OURS: Added `mode === 'strict'` parameter (sync)
- THEIRS: Changed to async version
- Both changes affect the same call but are incompatible
**Resolution:**
```javascript
// Combine both: use async version + add mode parameter
const result = await calculateAsync(a, b, mode === "strict");
```
**Note:** This assumes `calculateAsync` supports the third parameter. If not, may need to update the function signature.
#### Pattern 4: Delete vs Modify
```
<<<<<<< HEAD
function helper(x) {
return x * 2 + offset; // We modified: added '+ offset'
}
||||||| base
function helper(x) {
return x * 2;
}
=======
// They deleted the entire function
>>>>>>> feature
```
**Analysis:**
- OURS: Modified function logic
- THEIRS: Deleted function entirely
- Need to determine: Why was it deleted? Is our modification still needed?
**Resolution Strategy:**
1. Check if function is still called anywhere
2. If not called: Accept deletion (THEIRS)
3. If still called: Keep modified version (OURS) or refactor to new approach
4. If they replaced it with different implementation: Migrate our changes to new implementation
#### Pattern 5: Rename + References
```
<<<<<<< HEAD
const userData = getUserData();
processUserData(userData);
validateUserData(userData); // We added this line
||||||| base
const userData = getUserData();
processUserData(userData);
=======
const userProfile = getUserData(); // They renamed userData -> userProfile
processUserData(userProfile);
>>>>>>> feature
```
**Analysis:**
- OURS: Added new reference to `userData`
- THEIRS: Renamed `userData` to `userProfile` throughout
- Need to apply rename to our new line too
**Resolution:**
```javascript
const userProfile = getUserData();
processUserData(userProfile);
validateUserData(userProfile); // Use their new name
```
## Modern Improvement: zdiff3
**zdiff3** (Zealous diff3) is a newer variant that extracts common lines outside conflict markers:
### Standard diff3:
```
<<<<<<< HEAD
function foo() {
console.log('start');
processA();
console.log('end');
}
||||||| base
function foo() {
console.log('start');
console.log('end');
}
=======
function foo() {
console.log('start');
processB();
console.log('end');
}
>>>>>>> feature
```
### zdiff3:
```
function foo() {
console.log('start');
<<<<<<< HEAD
processA();
||||||| base
=======
processB();
>>>>>>> feature
console.log('end');
}
```
**Benefit:** Conflict is more compact and focused on actual differences.
**Enable zdiff3:**
```bash
git config --global merge.conflictstyle zdiff3
```
**Enable standard diff3:**
```bash
git config --global merge.conflictstyle diff3
```
## Semantic Merge Concepts
### Text-Based vs Semantic Conflicts
**Text-based merge** (Git default):
- Treats files as lines of text
- Conflicts when same lines modified
- No understanding of code structure
**Semantic merge**:
- Parses code structure (classes, functions, methods)
- Understands language syntax
- Can merge changes to different methods even if lines overlap
- Tools: SemanticMerge, AI-powered tools
### Example: False Conflict
Text-based tools see this as a conflict:
```
class User {
<<<<<<< HEAD
getName() { return this.name; }
getEmail() { return this.email; }
||||||| base
getName() { return this.name; }
=======
getName() { return this.name; }
getAge() { return this.age; }
>>>>>>> feature
}
```
Semantic tools recognize:
- OURS: Added `getEmail()` method
- THEIRS: Added `getAge()` method
- Both are compatible additions to different methods
- **Auto-resolve:** Keep both methods
## Resolution Workflow
### 1. Understand Context First
```bash
# See what each branch was trying to accomplish
git log --oneline HEAD ^origin/main
git log --oneline origin/main ^HEAD
# See who made the conflicting changes
git log --all --source -- path/to/conflicted/file.ts
```
### 2. Analyze Each Conflict
For each conflict marker block:
1. **Identify the change types:**
- Addition (new lines)
- Deletion (lines removed)
- Modification (lines changed)
- Movement (code reorganized)
2. **Determine intent:**
- Bug fix
- Feature addition
- Refactoring
- Performance optimization
- Style/formatting change
3. **Classify conflict:**
- Compatible: Changes to different concerns
- Redundant: Same goal, different approach
- Conflicting: Incompatible changes
### 3. Apply Resolution Pattern
Choose appropriate pattern from above based on classification.
### 4. Verify Resolution
After resolving:
```bash
# Ensure code compiles
npm run type-check
# Run tests
npm test
# Check linting
npm run lint
# Format code
npm run format
```
### 5. Document Complex Resolutions
For non-obvious resolutions, add a comment:
```typescript
// Merge resolution: Combined feature-A's validation (line 10)
// with feature-B's async handling (line 15)
const result = await validateAndProcess(data);
```
Or add to commit message:
```
Merge branch 'feature-B' into feature-A
Resolved conflicts in src/processor.ts:
- Combined validation logic from feature-A with async handling from feature-B
- Kept feature-A's error handling as it's more comprehensive
- Applied feature-B's parameter rename throughout
```
## Best Practices
### 1. Enable Better Conflict Markers
```bash
# Use zdiff3 (recommended)
git config --global merge.conflictstyle zdiff3
# Or use standard diff3
git config --global merge.conflictstyle diff3
```
### 2. Enable Rerere (Reuse Recorded Resolution)
```bash
git config --global rerere.enabled true
```
This records conflict resolutions and auto-applies them if the same conflict appears again (e.g., when rebasing).
### 3. Merge Frequently
Teams that merge more frequently report 70% fewer conflicts. Long-lived branches = more conflicts.
### 4. Use Iterative Resolution
For large conflicts:
1. Resolve one conflict at a time
2. Test after each resolution
3. Commit intermediate states if needed (use `git commit --no-verify` to skip hooks)
4. Don't try to resolve everything at once
### 5. Use Visual Merge Tools
For complex conflicts, use a merge tool:
```bash
git mergetool
```
Popular options:
- **VS Code** (built-in, supports diff3 display)
- **kdiff3** (free, shows all 3 versions side-by-side)
- **Beyond Compare** (paid, excellent UI)
- **P4Merge** (free, 3-way view)
### 6. Communicate with Team
For complex merges:
1. **Before resolving:** Check with the other developer about their intent
2. **After resolving:** Have them review the merge commit
3. **Document:** Explain non-obvious resolutions in commit message
### 7. Test Thoroughly
Merge conflicts can create **semantic conflicts** that compile but don't work:
```typescript
// OURS: Changed parameter name
function process(userData) { ... }
// THEIRS: Added call with old parameter name
const result = process(userId); // ← Will pass type checking but break at runtime!
```
Always test merged code, even if it type-checks.
## Common Anti-Patterns to Avoid
### ❌ Anti-Pattern 1: Always Pick OURS or THEIRS
```bash
# Bad: Blindly accepting one side
git checkout --ours path/to/file.ts
git checkout --theirs path/to/file.ts
```
**Problem:** Discards potentially important changes from the other side.
**When it's OK:**
- Generated files (lockfiles, build artifacts)
- Files you're intentionally reverting
- Confirmed with the other developer
### ❌ Anti-Pattern 2: Ignoring the Base
```
# Trying to resolve by only looking at HEAD vs incoming
# without understanding what the original code was
```
**Problem:** Can't understand intent without seeing what changed.
**Solution:** Always use diff3/zdiff3 to see the base.
### ❌ Anti-Pattern 3: Fixing Bugs During Merge
```typescript
<<<<<<< HEAD
const result = calculate(a, b); // We know this has a bug
=======
const result = compute(a, b);
>>>>>>> feature
// Bad: Fixing the bug while resolving
const result = calculate(a, b, { strict: true }); // Fixed the bug too!
```
**Problem:** Mixes merge resolution with bug fixes, making it hard to review and debug.
**Solution:**
1. First: Resolve the conflict (choose one or combine)
2. Then: Make bug fix in a separate commit
3. Or: Fix bug in both branches before merging
### ❌ Anti-Pattern 4: Resolving Without Testing
```bash
# Bad workflow
git merge feature-branch
# ... resolve conflicts ...
git commit
git push
```
**Problem:** Merged code might not work even if it compiles.
**Solution:**
```bash
git merge feature-branch
# ... resolve conflicts ...
npm run pre-commit # Type check, lint, format
npm test # Run tests
# Manual testing if UI changes
git commit
```
### ❌ Anti-Pattern 5: Making Large Changes During Resolution
```typescript
<<<<<<< HEAD
function processData(data) {
return transform(data);
}
=======
async function processDataAsync(data) {
return await asyncTransform(data);
}
>>>>>>> feature
// Bad: Major refactoring during merge resolution
async function processData(data, options = {}) {
// Added new options parameter
// Changed error handling
// Added caching layer
// etc...
}
```
**Problem:** Merge commits should be minimal and reviewable.
**Solution:**
1. Resolve the immediate conflict minimally
2. Make additional improvements in follow-up commits
3. Keep merge commits focused on resolution only
## Debugging Failed Resolutions
### The code compiles but doesn't work?
1. **Check for semantic conflicts:**
- Function renamed but old name used somewhere
- Parameter added but not passed in all call sites
- Return type changed but caller expects old type
2. **Search for partial migrations:**
```bash
# Find references to old names
git grep "oldFunctionName"
# Find TODO/FIXME added during merge
git grep -E "(TODO|FIXME).*merge"
```
3. **Compare with both branches:**
```bash
# What did each branch have that's now missing?
git diff HEAD origin/main -- path/to/file.ts
git diff HEAD feature-branch -- path/to/file.ts
```
### Tests fail after merge?
1. **Run tests from each branch separately:**
```bash
git checkout origin/main
npm test # Should pass
git checkout feature-branch
npm test # Should pass
git checkout merge-commit
npm test # Fails? Find out why
```
2. **Check for missing dependencies:**
- Did one branch add a new package?
- Run `npm install` after merge
3. **Look for context-dependent code:**
- Code that works differently when both changes are present
- Example: Two branches both adding the same event listener
## When to Ask for Help
Resolve conflicts yourself when:
- Changes are to different parts of the code
- Intent is clear from diff3 comparison
- Resolution is straightforward (add both changes, pick one, etc.)
Ask the other developer when:
- Changes represent different architectural decisions
- You don't understand the intent of their changes
- The conflict affects core business logic
- Multiple files are interconnected in complex ways
Ask a senior developer / architect when:
- Conflict reveals deeper architectural issues
- Both approaches have significant tradeoffs
- Resolution requires changing the architecture
- Conflict affects critical production code
## Quick Reference: Resolution Checklist
```
□ Enabled diff3 or zdiff3 conflict style
□ Understood what each branch was trying to accomplish
□ For each conflict:
□ Identified what OURS changed vs BASE
□ Identified what THEIRS changed vs BASE
□ Classified conflict type (compatible/redundant/conflicting)
□ Applied appropriate resolution pattern
□ Verified resolution makes semantic sense
□ Removed all conflict markers (<<<, |||, ===, >>>)
□ Ran type checking (npm run type-check)
□ Ran linting (npm run lint)
□ Ran tests (npm test)
□ Manually tested if UI changes
□ Documented complex resolutions in commit message
□ Had other developer review if needed
```
## Resources
- [Git SCM: Advanced Merging](https://git-scm.com/book/en/v2/Git-Tools-Advanced-Merging)
- [Take the pain out of git conflict resolution: use diff3](https://blog.nilbus.com/take-the-pain-out-of-git-conflict-resolution-use-diff3/)
- [Finding Joy in Git Conflict Resolution](https://technology.doximity.com/articles/finding-joy-in-git-conflict-resolution)
- [Use zdiff3 for easier merge conflict resolution](https://mopacic.net/til/2024/02/24/zdiff3.html)
## Summary
**Key takeaways:**
1. **Always use diff3/zdiff3** - Seeing the base is crucial for understanding intent
2. **Classify before resolving** - Understand the type of conflict (compatible/redundant/conflicting)
3. **Apply patterns** - Use established resolution patterns for common scenarios
4. **Test thoroughly** - Conflicts can create semantic issues that compile but don't work
5. **Communicate** - Don't guess at intent, ask the other developer if unclear
6. **Document complex resolutions** - Help reviewers and future debuggers
**Remember:** Merge conflicts are not just about making the code compile. They're about **preserving the intent of both sets of changes** while maintaining code correctness and quality.

View File

@@ -10,8 +10,8 @@ Panda CSS's `css()` function requires **static values at build time**. It cannot
```typescript
// ❌ This doesn't work
const color = "blue.400";
css({ color: color }); // Panda can't resolve this at build time
const color = 'blue.400'
css({ color: color }) // Panda can't resolve this at build time
```
The `css()` function performs static analysis during the build process to generate CSS classes. It cannot handle runtime-dynamic token paths.
@@ -40,7 +40,6 @@ const stages = [
1. **Use `as const`**: TypeScript needs the array marked as `const` so the token strings are treated as literal types, not generic strings. The `token()` function expects the `Token` literal type.
2. **Use inline styles**: When using `token()`, apply colors via the `style` prop, not through the `css()` function:
```typescript
// ✅ Correct
<div style={{ color: token(stage.color) }}>
@@ -52,13 +51,12 @@ const stages = [
3. **Static tokens in css()**: For static usage, you CAN use tokens directly in `css()`:
```typescript
// ✅ This works because it's static
css({ color: "blue.400" });
css({ color: 'blue.400' })
```
## How token() Works
The `token()` function:
- Takes a token path like `"colors.blue.400"`
- Looks it up in the generated token registry (`styled-system/tokens/index.mjs`)
- Returns the actual CSS value (e.g., `"#60a5fa"`)
@@ -67,7 +65,6 @@ The `token()` function:
## Token Type Definition
The `Token` type is a union of all valid token paths:
```typescript
type Token = "colors.blue.400" | "colors.green.400" | "colors.violet.400" | ...
```

View File

@@ -1,605 +0,0 @@
# Per-Player Stats Architecture & Implementation Plan
## Executive Summary
This document outlines the architecture for tracking game statistics per-player (not per-user). Each local player profile will maintain their own game history, wins, losses, and performance metrics. We'll build a universal framework that any arcade game can use to record results.
**Starting point**: Matching/Memory Lightning game
## Current State Problems
1. ❌ Global `user_stats` table exists but games never update it
2.`/games` page shows same global stats for all players
3. ❌ No framework for games to save results
4. ❌ Players table has no stats fields
## Architecture Design
### 1. Database Schema
#### New Table: `player_stats`
```sql
CREATE TABLE player_stats (
player_id TEXT PRIMARY KEY REFERENCES players(id) ON DELETE CASCADE,
-- Aggregate stats
games_played INTEGER NOT NULL DEFAULT 0,
total_wins INTEGER NOT NULL DEFAULT 0,
total_losses INTEGER NOT NULL DEFAULT 0,
-- Performance metrics
best_time INTEGER, -- Best completion time (ms)
highest_accuracy REAL NOT NULL DEFAULT 0, -- 0.0 - 1.0
-- Game preferences
favorite_game_type TEXT, -- Most played game
-- Per-game stats (JSON)
game_stats TEXT NOT NULL DEFAULT '{}', -- { "matching": { wins: 5, played: 10 }, ... }
-- Timestamps
last_played_at INTEGER, -- timestamp
created_at INTEGER NOT NULL, -- timestamp
updated_at INTEGER NOT NULL -- timestamp
);
CREATE INDEX player_stats_last_played_idx ON player_stats(last_played_at);
```
#### Per-Game Stats Structure (JSON)
```typescript
type PerGameStats = {
[gameName: string]: {
gamesPlayed: number;
wins: number;
losses: number;
bestTime: number | null;
highestAccuracy: number;
averageScore: number;
lastPlayed: number; // timestamp
};
};
```
#### Keep `user_stats`?
**Decision**: Deprecate `user_stats` table. All stats are now per-player.
**Reasoning**:
- Users can have multiple players
- Aggregate "user level" stats can be computed by summing player stats
- Simpler mental model: players compete, players have stats
- `/games` page displays players, so showing player stats makes sense
### 2. Universal Game Result Types
**Analysis**: Examined 5 arcade games (Matching, Complement Race, Memory Quiz, Card Sorting, Rithmomachia)
**Key Finding**: Cooperative games need special handling - all players share win/loss!
**See**: `.claude/GAME_STATS_COMPARISON.md` for detailed cross-game analysis
```typescript
// src/lib/arcade/stats/types.ts
/**
* Standard game result that all arcade games must provide
*
* Supports:
* - 1-N players
* - Competitive (individual winners)
* - Cooperative (team wins/losses)
* - Solo completion
* - Head-to-head (2-player)
*/
export interface GameResult {
// Game identification
gameType: string; // e.g., "matching", "complement-race", "memory-quiz"
// Player results (for multiplayer, array of results)
playerResults: PlayerGameResult[];
// Game metadata
completedAt: number; // timestamp
duration: number; // milliseconds
// Optional game-specific data
metadata?: {
// For cooperative games (Memory Quiz, Card Sorting collaborative)
isTeamVictory?: boolean; // All players share win/loss
// For specific win conditions (Rithmomachia)
winCondition?: string; // e.g., "HARMONY", "POINTS", "TIMEOUT"
// For game modes
gameMode?: string; // e.g., "solo", "competitive", "cooperative"
// Extensible for other game-specific info
[key: string]: unknown;
};
}
export interface PlayerGameResult {
playerId: string;
// Outcome
won: boolean; // For cooperative: all players have same value
placement?: number; // 1st, 2nd, 3rd place (for tournaments with 3+ players)
// Performance
score?: number;
accuracy?: number; // 0.0 - 1.0
completionTime?: number; // milliseconds (player-specific)
// Game-specific metrics (stored as JSON in DB)
metrics?: {
// Matching
moves?: number;
matchedPairs?: number;
difficulty?: number;
// Complement Race
streak?: number;
correctAnswers?: number;
totalQuestions?: number;
// Memory Quiz
correct?: number;
incorrect?: number;
// Card Sorting
exactMatches?: number;
inversions?: number;
lcsLength?: number;
// Rithmomachia
capturedPieces?: number;
points?: number;
// Extensible for future games
[key: string]: unknown;
};
}
/**
* Stats update returned from API
*/
export interface StatsUpdate {
playerId: string;
previousStats: PlayerStats;
newStats: PlayerStats;
changes: {
gamesPlayed: number;
wins: number;
losses: number;
};
}
export interface PlayerStats {
playerId: string;
gamesPlayed: number;
totalWins: number;
totalLosses: number;
bestTime: number | null;
highestAccuracy: number;
favoriteGameType: string | null;
gameStats: PerGameStats;
lastPlayedAt: number | null;
createdAt: number;
updatedAt: number;
}
```
### 3. API Endpoints
#### POST `/api/player-stats/record-game`
Records a game result and updates player stats.
**Request:**
```typescript
{
gameResult: GameResult;
}
```
**Response:**
```typescript
{
success: true,
updates: StatsUpdate[] // One per player
}
```
**Logic:**
1. Validate game result structure
2. For each player result:
- Fetch or create player_stats record
- Increment games_played
- Increment wins/losses based on outcome
- **Special case**: If `metadata.isTeamVictory === true`, all players share win/loss
- Cooperative games: all win or all lose together
- Competitive games: individual outcomes
- Update best_time if improved
- Update highest_accuracy if improved
- Update game-specific stats in JSON
- Update favorite_game_type based on most played
- Set last_played_at
3. Return updates for all players
**Example pseudo-code**:
```typescript
for (const playerResult of gameResult.playerResults) {
const stats = await getPlayerStats(playerResult.playerId);
stats.gamesPlayed++;
// Handle cooperative games specially
if (gameResult.metadata?.isTeamVictory !== undefined) {
// Cooperative: all players share outcome
if (playerResult.won) {
stats.totalWins++;
} else {
stats.totalLosses++;
}
} else {
// Competitive/Solo: individual outcome
if (playerResult.won) {
stats.totalWins++;
} else {
stats.totalLosses++;
}
}
// ... rest of stats update
}
```
#### GET `/api/player-stats/:playerId`
Fetch stats for a specific player.
**Response:**
```typescript
{
stats: PlayerStats;
}
```
#### GET `/api/player-stats`
Fetch stats for all current user's players.
**Response:**
```typescript
{
playerStats: PlayerStats[]
}
```
### 4. React Hooks
#### `useRecordGameResult()`
Main hook that games use to record results.
```typescript
// src/hooks/useRecordGameResult.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { GameResult, StatsUpdate } from "@/lib/arcade/stats/types";
export function useRecordGameResult() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (gameResult: GameResult): Promise<StatsUpdate[]> => {
const res = await fetch("/api/player-stats/record-game", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gameResult }),
});
if (!res.ok) throw new Error("Failed to record game result");
const data = await res.json();
return data.updates;
},
onSuccess: (updates) => {
// Invalidate player stats queries to trigger refetch
queryClient.invalidateQueries({ queryKey: ["player-stats"] });
// Show success feedback (optional)
console.log("✅ Game result recorded:", updates);
},
onError: (error) => {
console.error("❌ Failed to record game result:", error);
},
});
}
```
#### `usePlayerStats(playerId?)`
Fetch stats for a player (or all players if no ID).
```typescript
// src/hooks/usePlayerStats.ts
import { useQuery } from "@tanstack/react-query";
import type { PlayerStats } from "@/lib/arcade/stats/types";
export function usePlayerStats(playerId?: string) {
return useQuery({
queryKey: playerId ? ["player-stats", playerId] : ["player-stats"],
queryFn: async (): Promise<PlayerStats | PlayerStats[]> => {
const url = playerId
? `/api/player-stats/${playerId}`
: "/api/player-stats";
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to fetch player stats");
const data = await res.json();
return playerId ? data.stats : data.playerStats;
},
});
}
```
### 5. Game Integration Pattern
Every arcade game should follow this pattern when completing:
```typescript
// In results phase component (e.g., ResultsPhase.tsx)
import { useRecordGameResult } from '@/hooks/useRecordGameResult'
import type { GameResult } from '@/lib/arcade/stats/types'
export function ResultsPhase() {
const { state, activePlayers } = useGameContext()
const { mutate: recordGame, isPending } = useRecordGameResult()
// Record game result on mount (once)
useEffect(() => {
if (state.phase === 'results' && !state.recorded) {
const gameResult: GameResult = {
gameType: 'matching',
completedAt: Date.now(),
duration: state.gameEndTime - state.gameStartTime,
playerResults: activePlayers.map(player => ({
playerId: player.id,
won: player.id === winnerId,
score: player.matchCount,
accuracy: player.matchCount / state.totalPairs,
completionTime: player.completionTime,
})),
}
recordGame(gameResult, {
onSuccess: () => {
// Mark as recorded to prevent duplicates
setState({ recorded: true })
}
})
}
}, [state.phase, state.recorded])
// Show loading state while recording
if (isPending) {
return <div>Saving results...</div>
}
// Show results UI
return <div>...</div>
}
```
## Implementation Plan
### Phase 1: Foundation (Database & API)
1. **Create database schema**
- File: `src/db/schema/player-stats.ts`
- Define `player_stats` table with Drizzle ORM
- Add type exports
2. **Generate migration**
```bash
npx drizzle-kit generate:sqlite
```
3. **Create type definitions**
- File: `src/lib/arcade/stats/types.ts`
- Define `GameResult`, `PlayerGameResult`, `StatsUpdate`, `PlayerStats`
4. **Build API endpoint**
- File: `src/app/api/player-stats/record-game/route.ts`
- Implement POST handler with validation
- Handle per-player stat updates
- Transaction safety
5. **Build query endpoints**
- File: `src/app/api/player-stats/route.ts` (GET all)
- File: `src/app/api/player-stats/[playerId]/route.ts` (GET one)
### Phase 2: React Hooks & Integration
6. **Create React hooks**
- File: `src/hooks/useRecordGameResult.ts`
- File: `src/hooks/usePlayerStats.ts`
7. **Update GameModeContext**
- Expose helper to get player stats map
- Integrate with usePlayerStats hook
### Phase 3: Matching Game Integration
8. **Analyze matching game completion flow**
- Find where game completes
- Identify winner calculation
- Map state to GameResult format
9. **Integrate stats recording**
- Add useRecordGameResult to ResultsPhase
- Build GameResult from game state
- Handle recording state to prevent duplicates
10. **Test matching game stats**
- Play solo game, verify stats update
- Play multiplayer game, verify all players update
- Check accuracy calculations
- Check time tracking
### Phase 4: UI Updates
11. **Update /games page**
- Fetch per-player stats with usePlayerStats
- Display correct stats for each player card
- Remove dependency on global user profile
12. **Add stats visualization**
- Per-game breakdown
- Win/loss ratio
- Performance trends
### Phase 5: Documentation & Rollout
13. **Document integration pattern**
- Create guide for adding stats to other games
- Code examples
- Common pitfalls
14. **Roll out to other games**
- Complement Race
- Memory Quiz
- Card Sorting
- (Future games)
## Data Migration Strategy
### Handling Existing `user_stats`
**Option A: Drop the table**
- Simple, clean break
- No historical data
**Option B: Migrate to player stats**
- For each user with stats, assign to their first/active player
- More complex but preserves history
**Recommendation**: Option A (drop it) since:
- Very new feature, unlikely much data exists
- Cleaner architecture
- Users can rebuild stats by playing
### Migration SQL
```sql
-- Drop old user_stats table
DROP TABLE IF EXISTS user_stats;
-- Create new player_stats table
-- (Drizzle migration will handle this)
```
## Testing Strategy
### Unit Tests
- `GameResult` validation
- Stats calculation logic
- JSON merge for per-game stats
- Favorite game detection
### Integration Tests
- API endpoint: record game, verify DB update
- API endpoint: fetch stats, verify response
- React hook: record game, verify cache invalidation
### E2E Tests
- Play matching game solo, check stats on /games page
- Play matching game multiplayer, verify each player's stats
- Verify stats persist across sessions
## Success Criteria
✅ Player stats save correctly after game completion
✅ Each player maintains separate stats
✅ /games page displays correct per-player stats
✅ Stats survive page refresh
✅ Multiplayer games update all participants
✅ Framework is reusable for other games
✅ No duplicate recordings
✅ Performance acceptable (< 200ms to record)
## Open Questions
1. **Leaderboards?** - Future consideration, need global rankings
2. **Historical games?** - Store individual game records or just aggregates?
3. **Stats reset?** - Should users be able to reset player stats?
4. **Achievements?** - Track milestones? (100 games, 50 wins, etc.)
## File Structure
```
src/
├── db/
│ └── schema/
│ └── player-stats.ts # NEW: Drizzle schema
├── lib/
│ └── arcade/
│ └── stats/
│ ├── types.ts # NEW: Type definitions
│ └── utils.ts # NEW: Helper functions
├── hooks/
│ ├── useRecordGameResult.ts # NEW: Record game hook
│ └── usePlayerStats.ts # NEW: Fetch stats hook
├── app/
│ └── api/
│ └── player-stats/
│ ├── route.ts # NEW: GET all
│ ├── record-game/
│ │ └── route.ts # NEW: POST record
│ └── [playerId]/
│ └── route.ts # NEW: GET one
└── arcade-games/
└── matching/
└── components/
└── ResultsPhase.tsx # MODIFY: Add stats recording
.claude/
└── PER_PLAYER_STATS_ARCHITECTURE.md # THIS FILE
```
## Next Steps
1. Review this plan with user
2. Create database schema and types
3. Build API endpoints
4. Create React hooks
5. Integrate with matching game
6. Test thoroughly
7. Roll out to other games
---
**Document Status**: Draft for review
**Last Updated**: 2025-01-03
**Owner**: Claude Code

File diff suppressed because it is too large Load Diff

View File

@@ -1,435 +0,0 @@
# PlayingGuideModal - Complete Feature Specification
## Overview
Interactive, draggable, resizable modal for Rithmomachia game guide with i18n support and bust-out functionality.
## File Location
`src/arcade-games/rithmomachia/components/PlayingGuideModal.tsx`
## Dependencies
```typescript
import { useEffect, useState, useRef } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { useTranslation } from "react-i18next";
import { css } from "../../../../styled-system/css";
import { Z_INDEX } from "@/constants/zIndex";
import { useAbacusSettings } from "@/hooks/useAbacusSettings";
import { PieceRenderer } from "./PieceRenderer";
import { RithmomachiaBoard, type ExamplePiece } from "./RithmomachiaBoard";
import type { PieceType, Color } from "../types";
import "../i18n/config"; // Initialize i18n
```
## Props Interface
```typescript
interface PlayingGuideModalProps {
isOpen: boolean; // Controls visibility
onClose: () => void; // Called when modal closes
standalone?: boolean; // True when opened in popup window (full-screen mode)
}
```
## State Management
### Required State
```typescript
const { t, i18n } = useTranslation();
const { data: abacusSettings } = useAbacusSettings();
const useNativeAbacusNumbers = abacusSettings?.nativeAbacusNumbers ?? false;
const [activeSection, setActiveSection] = useState<Section>("overview");
const [position, setPosition] = useState({ x: 0, y: 0 });
const [size, setSize] = useState({ width: 450, height: 600 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<string>("");
const [isHovered, setIsHovered] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
```
### Section Type
```typescript
type Section =
| "overview"
| "pieces"
| "capture"
| "strategy"
| "harmony"
| "victory";
```
## Core Features
### 1. Radix Dialog Wrapper
**When NOT standalone:**
- Wrap entire modal in `<Dialog.Root open={isOpen} onOpenChange={onClose}>`
- Use `<Dialog.Portal>` for portal rendering
- Use `<Dialog.Overlay>` with backdrop styling
- Use `<Dialog.Content>` as container for draggable/resizable content
**Styling:**
- Overlay: semi-transparent black (`rgba(0, 0, 0, 0.5)`)
- Content: no default positioning (we control via position state)
- Z-index: Must be above game board - use `Z_INDEX.GAME.GUIDE_MODAL` or 15000+
**When standalone:**
- Skip Dialog wrapper entirely
- Render full-screen fixed container
### 2. Draggable Functionality
**Requirements:**
- Click and drag from header to move modal
- Disabled on mobile (`window.innerWidth < 768`)
- Cursor changes to 'move' when hovering header
- Position state tracks x, y coordinates
**Implementation:**
```typescript
const handleMouseDown = (e: React.MouseEvent) => {
if (window.innerWidth < 768) return;
setIsDragging(true);
setDragStart({
x: e.clientX - position.x,
y: e.clientY - position.y,
});
};
```
**Effects:**
- Global `mousemove` listener updates position while dragging
- Global `mouseup` listener stops dragging
- Cleanup listeners on unmount
### 3. Resizable Functionality
**Requirements:**
- 8 resize handles: N, S, E, W, NE, NW, SE, SW
- Handles visible only on hover (when `isHovered === true`)
- Disabled on mobile
- Min size: 450x600
- Max size: 90vw x 80vh
**Handle Positions & Cursors:**
- N (top): `cursor: 'ns-resize'`
- S (bottom): `cursor: 'ns-resize'`
- E (right): `cursor: 'ew-resize'`
- W (left): `cursor: 'ew-resize'`
- NE (top-right): `cursor: 'nesw-resize'`
- NW (top-left): `cursor: 'nwse-resize'`
- SE (bottom-right): `cursor: 'nwse-resize'`
- SW (bottom-left): `cursor: 'nesw-resize'`
**Handle Styling:**
- Width/height: 8px (invisible hit area)
- Visible border when hovered: 2px solid blue
- Positioned absolutely at edges/corners
**Implementation:**
```typescript
const handleResizeStart = (e: React.MouseEvent, direction: string) => {
if (window.innerWidth < 768) return;
e.stopPropagation();
setIsResizing(true);
setResizeDirection(direction);
setDragStart({ x: e.clientX, y: e.clientY });
};
```
### 4. Bust-Out Button
**Location:** Header, right side (before close button)
**Icon:** ↗ or external link icon
**Functionality:**
```typescript
const handleBustOut = () => {
const url = window.location.origin + "/arcade/rithmomachia/guide";
const features =
"width=600,height=800,menubar=no,toolbar=no,location=no,status=no";
window.open(url, "RithmomachiaGuide", features);
};
```
**Visibility:** Only show if NOT already standalone
**Route:** Must have a route at `/arcade/rithmomachia/guide` that renders:
```tsx
<PlayingGuideModal
isOpen={true}
onClose={() => window.close()}
standalone={true}
/>
```
### 5. Internationalization
**Setup:**
- i18n config file: `src/arcade-games/rithmomachia/i18n/config.ts`
- Translation files in: `src/arcade-games/rithmomachia/i18n/locales/`
- Languages: en.json, de.json (minimum)
**Usage:**
- All text uses `t('guide.section.key')` format
- Language switcher in header with buttons for each language
**Header Language Switcher:**
```tsx
<div className={css({ display: "flex", gap: "8px" })}>
{["en", "de"].map((lang) => (
<button
key={lang}
onClick={() => i18n.changeLanguage(lang)}
className={css({
px: "8px",
py: "4px",
fontSize: "12px",
fontWeight: i18n.language === lang ? "bold" : "normal",
bg: i18n.language === lang ? "#3b82f6" : "#e5e7eb",
color: i18n.language === lang ? "white" : "#374151",
border: "none",
borderRadius: "4px",
cursor: "pointer",
})}
>
{lang.toUpperCase()}
</button>
))}
</div>
```
### 6. Centering on Mount
**Effect:**
```typescript
useEffect(() => {
if (isOpen && modalRef.current && !standalone) {
const rect = modalRef.current.getBoundingClientRect();
setPosition({
x: (window.innerWidth - rect.width) / 2,
y: Math.max(50, (window.innerHeight - rect.height) / 2),
});
}
}, [isOpen, standalone]);
```
**Standalone Mode:**
- If standalone, don't center - use full viewport
- Position: fixed, top: 0, left: 0, width: 100vw, height: 100vh
## Layout Structure
```
<Dialog.Root> (if not standalone)
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content asChild>
<div ref={modalRef} style={{ position: absolute, top: position.y, left: position.x }}>
{/* Resize handles (8 total, only if hovered and not mobile) */}
<div> {/* Main container */}
{/* Header */}
<div onMouseDown={handleMouseDown} style={{ cursor: isDragging ? 'grabbing' : 'grab' }}>
<h2>{t('guide.title')}</h2>
<div> {/* Language switcher */}
<button onClick={handleBustOut}> {/* Bust-out (if not standalone) */}
<button onClick={onClose}> {/* Close X */}
</div>
{/* Navigation tabs */}
<div> {/* Section buttons: Overview, Pieces, Capture, Strategy, Harmony, Victory */}
{/* Content area - scrollable */}
<div style={{ overflow: 'auto', maxHeight: size.height - headerHeight }}>
{activeSection === 'overview' && <OverviewSection />}
{activeSection === 'pieces' && <PiecesSection useNativeAbacusNumbers={useNativeAbacusNumbers} />}
{activeSection === 'capture' && <CaptureSection />}
{/* ... etc */}
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
```
## Styling Requirements
### Main Container
- Background: `#ffffff`
- Border radius: `12px`
- Box shadow: `0 20px 60px rgba(0, 0, 0, 0.3)`
- Border: `1px solid #e5e7eb`
- Position: `absolute` (controlled by position state)
- Width/height: from size state
### Header
- Background: `#f9fafb`
- Border bottom: `1px solid #e5e7eb`
- Padding: `16px`
- Display: flex, justify-between, align-items: center
- Cursor: `move` on desktop (when not standalone)
- Prevent text selection while dragging
### Navigation Tabs
- Display: flex, gap: `8px`
- Padding: `12px 16px`
- Background: `#ffffff`
- Border bottom: `1px solid #e5e7eb`
### Tab Buttons
- Active: bold, blue background, white text
- Inactive: normal weight, gray background, dark text
- Padding: `8px 16px`
- Border radius: `6px`
- Cursor: pointer
- Transition: all 0.2s
### Content Area
- Padding: `24px`
- Overflow: auto
- Max height: calculated (size.height - header - tabs)
- Color: `#374151`
- Line height: `1.6`
### Resize Handles
- Position: absolute
- Width/height: 8px
- Background: transparent
- Border: visible on hover (2px solid `#3b82f6`)
- Z-index: 1 (above content)
## Content Sections
### PiecesSection Component
**Must have its own useAbacusSettings hook:**
```typescript
function PiecesSection() {
const { data: abacusSettings } = useAbacusSettings();
const useNativeAbacusNumbers = abacusSettings?.nativeAbacusNumbers ?? false;
// ... piece rendering with useNativeAbacusNumbers prop
}
```
### All RithmomachiaBoard Uses
- Must pass `useNativeAbacusNumbers={useNativeAbacusNumbers}` prop
- Boards show game positions with pieces
### All PieceRenderer Uses
- Must pass `useNativeAbacusNumbers={useNativeAbacusNumbers}` prop
- Renders individual piece icons in pieces section
## Translation Keys (Minimum Required)
```json
{
"guide": {
"title": "Rithmomachia Playing Guide",
"overview": {
"title": "Overview",
"content": "..."
},
"pieces": {
"title": "Your Pieces",
"circle": "Circle",
"triangle": "Triangle",
"square": "Square",
"pyramid": "Pyramid"
},
"capture": {
"title": "Capture Rules",
"equality": "Equality",
"multiple": "Multiple",
"ratio": "Ratio",
"sum": "Sum",
"difference": "Difference",
"product": "Product"
},
"strategy": {
"title": "Strategy Tips"
},
"harmony": {
"title": "Harmony (Progressions)"
},
"victory": {
"title": "Victory Conditions"
}
}
}
```
## Error Prevention
1. **Z-Index Issue:** Must be higher than game board (use `Z_INDEX.GAME.GUIDE_MODAL` or 15000+)
2. **Lost Work:** Never use `git checkout --` on working files without confirming stash/commit first
3. **Dialog Overlay:** Must render with high z-index to cover game
4. **Mobile:** Disable drag/resize on mobile, make responsive
5. **Standalone Route:** Must exist at `/arcade/rithmomachia/guide`
## Testing Checklist
- [ ] Modal opens and closes correctly
- [ ] Dragging works on desktop
- [ ] Resizing works on desktop (all 8 handles)
- [ ] Drag/resize disabled on mobile
- [ ] Language switcher changes content
- [ ] Bust-out button opens new window
- [ ] New window renders standalone mode correctly
- [ ] Modal appears above game board
- [ ] Close button works
- [ ] All sections render correctly
- [ ] Native abacus numbers toggle respected
- [ ] Translations load for all languages
- [ ] Modal centers on first open
- [ ] Position/size persists while open
- [ ] Cleanup happens on unmount
## Implementation Priority
1. Basic Dialog structure with standalone mode
2. Header with title, close, bust-out
3. Language switcher and i18n setup
4. Navigation tabs
5. Content sections (start with existing content)
6. Dragging functionality
7. Resizing functionality
8. Native abacus numbers integration
9. Translation files
10. Standalone route page

View File

@@ -1,372 +0,0 @@
# Problem Generation System - Claude Code Reference
## Quick Reference for AI Development
This document provides quick-reference information about the worksheet problem generation system for Claude Code and developers working on this codebase.
---
## File Locations
### Core Logic
- **`src/app/create/worksheets/problemGenerator.ts`** - All generation algorithms (addition, subtraction, mixed)
- **`src/app/create/worksheets/utils/validateProblemSpace.ts`** - Space estimation and validation
- **`src/app/create/worksheets/PROBLEM_GENERATION_ARCHITECTURE.md`** - Complete technical documentation
### UI Components
- **`components/worksheet-preview/WorksheetPreviewContext.tsx`** - Validation triggering and state
- **`components/worksheet-preview/DuplicateWarningBanner.tsx`** - Warning display UI
---
## Two Generation Strategies
### When to Use Each
```typescript
const estimatedSpace = estimateUniqueProblemSpace(
digitRange,
pAnyStart,
operator,
);
if (estimatedSpace < 10000) {
// STRATEGY 1: Generate-All + Shuffle
// Zero retries, guaranteed coverage, deterministic
} else {
// STRATEGY 2: Retry-Based
// Random generation, allows some duplicates after 100 retries
}
```
### Strategy 1: Generate-All (Small Spaces)
**Examples:**
- 1-digit 100% regrouping: 45 unique problems
- 2-digit mixed regrouping: ~4,000 unique problems
**Key behavior:**
```typescript
// Non-interpolate: Shuffle and cycle
problems[0-44] = first shuffle
problems[45-89] = second shuffle (same order)
problems[90+] = third shuffle...
// Interpolate: Sort by difficulty, then cycle maintaining progression
seen.clear() when exhausted // Start new cycle
```
**Location:** `problemGenerator.ts:381-503`
### Strategy 2: Retry-Based (Large Spaces)
**Examples:**
- 3-digit problems: ~400,000 unique problems
- 4-5 digit problems: millions of unique problems
**Key behavior:**
```typescript
let tries = 0;
while (tries++ < 100 && !unique) {
problem = generate();
if (!seen.has(key)) {
seen.add(key);
break;
}
}
// Allow duplicate if still not unique after 100 tries
```
**Location:** `problemGenerator.ts:506-595`
---
## Critical Edge Cases
### 1. Single-Digit 100% Regrouping
**Problem:** Only 45 unique problems exist!
```typescript
// 1-digit addition where a + b >= 10
// a=0: none, a=1: 9 (1+9), a=2: 8 (2+8,2+9), ..., a=9: 1 (9+1)
// Total: 0+9+8+7+6+5+4+3+2+1 = 45
```
**User impact:**
- Requesting 100 problems → 55 duplicates guaranteed
- Warning banner shown: "Single-digit problems (1-9) with 100% regrouping have very few unique combinations!"
**Code:** `validateProblemSpace.ts:56-64` (exact count), `validateProblemSpace.ts:175-179` (warning)
### 2. Mixed Mode with Mastery
**Problem:** Cannot validate combined space with separate configs
```typescript
// Addition skill: {digitRange: {min:2, max:2}, pAnyStart: 0.3}
// Subtraction skill: {digitRange: {min:1, max:2}, pAnyStart: 0.7}
// Combined estimation is complex and potentially misleading
```
**Solution:** Skip validation entirely
```typescript
// WorksheetPreviewContext.tsx:53-56
if (mode === "mastery" && operator === "mixed") {
setWarnings([]);
return;
}
```
### 3. Subtraction Multiple Borrowing Impossibility
**Mathematical fact:** 1-2 digit subtraction cannot have 2+ borrows
```typescript
// generateBothBorrow() - problemGenerator.ts:802-804
if (maxDigits <= 2) {
return generateOnesOnlyBorrow(rand, minDigits, maxDigits); // Fallback
}
```
### 4. Borrowing Across Zeros
**Example:** `1000 - 1` requires 3 borrow operations
```
ones: 0 < 1, borrow from tens
tens: 0 (zero!), borrow from hundreds (+1 for crossing zero)
hundreds: 0, borrow from thousands (+1 for crossing zero)
thousands: 1, decrement
Total: 3 borrows
```
**Code:** `countBorrows()` - `problemGenerator.ts:740-782`
---
## Debugging Commands
### Check Server Logs for Generation
```bash
# Look for these log patterns:
[ADD GEN] Starting: 100 problems, digitRange: 1-1, pAnyStart: 1, pAllStart: 0
[ADD GEN] Estimated unique problem space: 45 (requesting 100)
[ADD GEN] Using generate-all + shuffle (space < 10000, interpolate=true)
[ADD GEN] Exhausted all 45 unique problems at position 45. Starting cycle 2.
[ADD GEN] Complete: 100 problems in 8ms (0 retries, generate-all with progressive difficulty, 1 cycles)
```
### Test Problem Space Estimation
```typescript
import { estimateUniqueProblemSpace } from "./utils/validateProblemSpace";
// 1-digit 100% regrouping
const space1 = estimateUniqueProblemSpace({ min: 1, max: 1 }, 1.0, "addition");
console.log(space1); // Expected: 45
// 2-digit mixed
const space2 = estimateUniqueProblemSpace({ min: 2, max: 2 }, 0.5, "addition");
console.log(space2); // Expected: ~4000
```
### Verify Cycling Behavior
```typescript
// Generate 100 problems from 45-problem space
const problems = generateProblems(100, 1.0, 0, false, 12345, {
min: 1,
max: 1,
});
// Check that problems 0-44 and 45-89 are identical
const cycle1 = problems.slice(0, 45);
const cycle2 = problems.slice(45, 90);
console.log(
"Cycles match:",
cycle1.every((p, i) => p.a === cycle2[i].a && p.b === cycle2[i].b),
); // Expected: true
```
---
## Common Modifications
### Adding a New Difficulty Category
**Current categories:** `non`, `onesOnly`, `both`
**To add a new category:**
1. Add category type to `ProblemCategory` in `types.ts`
2. Create generator function (e.g., `generateThreePlus()`)
3. Update probability sampling in retry-based strategy
4. Update `countRegroupingOperations()` or `countBorrows()` for difficulty scoring
5. Update `generateAllAdditionProblems()` or `generateAllSubtractionProblems()` filtering
### Changing Strategy Threshold
**Current:** 10,000 unique problems
```typescript
const THRESHOLD = 10000;
if (estimatedSpace < THRESHOLD) {
// Generate-all
} else {
// Retry-based
}
```
**To change:**
- Increase for better uniqueness guarantees (more generate-all usage)
- Decrease for better performance on larger spaces (more retry-based usage)
**Trade-off:** Generate-all is O(n²) enumeration, slow for large spaces
### Adjusting Retry Limit
**Current:** 100 retries per problem
```typescript
let tries = 0;
while (tries++ < 100 && !ok) {
// Generate and check uniqueness
}
```
**To change:**
- Increase for better uniqueness (slower generation)
- Decrease for faster generation (more duplicates)
**Historical note:** Was 3000 retries, reduced to 100 for performance
- 100 problems × 3000 retries = 300,000 iterations (seconds)
- 100 problems × 100 retries = 10,000 iterations (milliseconds)
---
## Problem Space Estimation Formulas
### Exact Counting (1-Digit)
```typescript
// Addition regrouping (a + b >= 10)
for (let a = 0; a <= 9; a++) {
for (let b = 0; b <= 9; b++) {
if (a + b >= 10) count++;
}
}
// Result: 45
// Addition non-regrouping
// Result: 100 - 45 = 55
```
### Heuristic Estimation (2+ Digits)
```typescript
numbersPerDigitCount = digits === 1 ? 10 : (9 * 10) ^ (digits - 1);
// Addition
pairsForDigits = numbersPerDigitCount * numbersPerDigitCount;
regroupFactor = pAnyStart > 0.8 ? 0.45 : pAnyStart > 0.5 ? 0.5 : 0.7;
totalSpace += pairsForDigits * regroupFactor;
// Subtraction (only minuend >= subtrahend valid)
pairsForDigits = (numbersPerDigitCount * numbersPerDigitCount) / 2;
borrowFactor = pAnyStart > 0.8 ? 0.35 : pAnyStart > 0.5 ? 0.5 : 0.7;
totalSpace += pairsForDigits * borrowFactor;
```
**Why these factors?**
- High regrouping requirement → Fewer valid problems
- Medium regrouping → About half
- Low/mixed → Most problems valid
---
## User Warning Levels
```typescript
const ratio = requestedProblems / estimatedSpace;
if (ratio < 0.3) {
duplicateRisk = "none"; // No warning shown
} else if (ratio < 0.5) {
duplicateRisk = "low"; // "Some duplicates may occur"
} else if (ratio < 0.8) {
duplicateRisk = "medium"; // "Expect moderate duplicates" + suggestions
} else if (ratio < 1.5) {
duplicateRisk = "high"; // "High duplicate risk!" + recommendations
} else {
duplicateRisk = "extreme"; // "Mostly duplicate problems" + strong warnings
}
```
**Location:** `validateProblemSpace.ts:130-172`
---
## Testing Checklist
When modifying problem generation:
- [ ] Test 1-digit 100% regrouping (45 unique)
- [ ] Test 2-digit mixed (should use generate-all)
- [ ] Test 3-digit (should use retry-based)
- [ ] Test cycling: Request 100 from 45-problem space
- [ ] Test progressive difficulty (interpolate=true)
- [ ] Test constant difficulty (interpolate=false)
- [ ] Test mixed mode (manual)
- [ ] Test mixed mode (mastery with separate configs)
- [ ] Test subtraction borrowing across zeros
- [ ] Test subtraction 2-digit multiple borrowing (should fallback)
- [ ] Verify server logs show correct strategy selection
- [ ] Verify warnings appear at correct thresholds
---
## Related Documentation
- **`PROBLEM_GENERATION_ARCHITECTURE.md`** - Complete technical documentation (read this first for deep understanding)
- **`CONFIG_SCHEMA_GUIDE.md`** - Worksheet configuration schema
- **`SMART_DIFFICULTY_SPEC.md`** - Smart mode difficulty progression
- **`SUBTRACTION_AND_OPERATOR_PLAN.md`** - Subtraction implementation plan
---
## Quick Answers
**Q: Why am I seeing duplicate problems?**
A: Check estimated space vs requested problems. If ratio > 1.0, duplicates are inevitable.
**Q: Why is generation slow?**
A: If using retry-based with constrained space (e.g., 2-digit 100% regrouping), switch to generate-all by increasing THRESHOLD.
**Q: Why does interpolate mode show different problems on second cycle?**
A: By design! It clears the "seen" set to allow re-sampling while maintaining difficulty progression.
**Q: Why is mastery+mixed mode not showing warnings?**
A: Validation is skipped because addition and subtraction have separate configs, making combined estimation complex.
**Q: Can I have 3+ borrows in 2-digit subtraction?**
A: No, mathematically impossible. `generateBothBorrow()` falls back to ones-only for maxDigits ≤ 2.
**Q: How do I test if a configuration will have many duplicates?**
A: Use `validateProblemSpace()` - it returns `duplicateRisk` level and `warnings` array.
**Q: What's the difference between addition and subtraction uniqueness?**
A: Addition is commutative (2+3 = 3+2, same problem). Subtraction is not (5-3 ≠ 3-5, different problems).

View File

@@ -1,158 +0,0 @@
# Plan: ProblemToReview Redesign
## Problem Statement
The current ProblemToReview component has redundant information - the collapsed view shows the problem, and expanding reveals DetailedProblemCard which shows the same problem again. Users need to quickly identify problems and understand why they went wrong.
## Design Goals
1. **Single problem representation** - never duplicate the problem display
2. **BKT-driven weak skill detection** - use mastery data to identify likely causes
3. **Progressive disclosure** - collapsed shows identification, expanded shows annotations
4. **Actionable insights** - surface what the student needs to work on
---
## Collapsed View
```
┌─────────────────────────────────────────────────────────────────┐
│ #5 🧮 Abacus ❌ Incorrect │
│ ┌─────┐ │
│ │ 5 │ │
│ │ + 4 │ = 9 [said 8] │
│ └─────┘ │
│ │
│ ⚠️ 5's: 4=5-1, 10's: 8=10-2 (+1 more) [▼] │
└─────────────────────────────────────────────────────────────────┘
```
**Elements:**
- Problem number + part type (🧮 Abacus, 🧠 Visualize, 💭 Mental)
- Problem in vertical format (even for linear problems)
- Wrong answer indicator: `[said 8]`
- Reason badges (❌ Incorrect, ⏱️ Slow, 💡 Help used)
- Weak skills summary: up to 3, ordered by BKT severity, "+N more" if truncated
- Expand button
---
## Expanded View
```
┌─────────────────────────────────────────────────────────────────┐
│ #5 🧮 Abacus ❌ Incorrect [▲] │
│ │
│ ┌─────┬───────────────────────────────────────────────────┐│
│ │ 5 │ direct addition ││
│ │ + 4 │ ⚠️ 5's: 4=5-1 ← likely cause ││
│ ├─────┼───────────────────────────────────────────────────┤│
│ │ = 9 │ [said 8] ││
│ └─────┴───────────────────────────────────────────────────┘│
│ │
│ ⚠️ Weak: 5's: 4=5-1 (23%), 10's: 8=10-2 (41%), +1 more │
│ │
│ ⏱️ 12.3s response (threshold: 8.5s) │
│ │
│ 🎯 Focus: Practicing a skill you're still learning, with │
│ scaffolding to build confidence. │
└─────────────────────────────────────────────────────────────────┘
```
**Elements:**
- Same header as collapsed
- Problem with skill annotations next to each term
- Weak skills marked with ⚠️ and "← likely cause" indicator
- Weak skills summary with BKT mastery percentages
- Timing info (response time vs threshold)
- Purpose with full explanation text
---
## Implementation Steps
### Step 1: Add BKT data to ProblemToReview props
- Add `skillMasteries: Record<string, number>` prop (skillId → mastery 0-1)
- Pass from SessionSummary which can compute from session or fetch from API
### Step 2: Create weak skill detection utility
- `getWeakSkillsForProblem(skillsExercised: string[], masteries: Record<string, number>)`
- Returns skills sorted by mastery (lowest first)
- Include mastery percentage for display
### Step 3: Create AnnotatedProblem component
- Single component that handles both collapsed and expanded states
- Vertical format for all problems (linear and vertical parts)
- In expanded mode: shows skill annotation next to each term
- Highlights weak skills with ⚠️ indicator
### Step 4: Create WeakSkillsSummary component
- Shows up to 3 weak skills, ordered by severity
- "+N more" indicator if truncated
- In expanded mode: includes mastery percentages
### Step 5: Create PurposeExplanation component
- Maps purpose (focus/reinforce/review/challenge) to explanation text
- Reuse or extract from existing purpose tooltip logic
### Step 6: Refactor ProblemToReview
- Remove DetailedProblemCard usage
- Use new AnnotatedProblem component
- Add WeakSkillsSummary to both views
- Add timing and purpose sections to expanded view
### Step 7: Update SessionSummary to pass BKT data
- Compute skill masteries from session results OR
- Fetch from /api/curriculum/[playerId]/skills endpoint
- Pass to ProblemToReview components
### Step 8: Hide costs in session summary context
- Add `showCosts` prop to any shared components
- Default false for session summary, true for plan review
---
## Files to Modify/Create
| File | Action |
| ------------------------------------------------ | ----------------------- |
| `src/components/practice/ProblemToReview.tsx` | Major refactor |
| `src/components/practice/AnnotatedProblem.tsx` | New component |
| `src/components/practice/WeakSkillsSummary.tsx` | New component |
| `src/components/practice/weakSkillUtils.ts` | New utility |
| `src/components/practice/SessionSummary.tsx` | Pass BKT data |
| `src/components/practice/purposeExplanations.ts` | New or extract existing |
---
## Data Flow
```
SessionSummary
├── Fetches/computes skill masteries
└── Passes to ProblemToReview
├── weakSkillUtils.getWeakSkillsForProblem()
├── AnnotatedProblem (collapsed or expanded)
├── WeakSkillsSummary
└── Timing + Purpose sections (expanded only)
```
---
## Open Questions (Resolved)
- ✅ Use BKT data for weak skill detection
- ✅ Show up to 3 weak skills, "+N more" if truncated
- ✅ Order by BKT severity (lowest mastery first)
- ✅ Show weak skills on correct problems too (if they're in review list for timing/help reasons)
- ✅ Single problem representation, annotated in expanded view

View File

@@ -1,248 +0,0 @@
# Progression Path Pedagogy
## Overview
The mastery progression system guides students through addition skills using research-based pedagogical scaffolding. This document describes the improved progression path that starts with foundational skills before introducing regrouping/carrying.
## Key Pedagogical Principles
### 1. **Foundation Before Complexity**
Students must master basic addition (sums ≤ 9) before learning carrying. This builds:
- Number sense and fact fluency
- Confidence with the addition operation
- Mental calculation strategies
### 2. **Graduated Difficulty**
Three levels of regrouping difficulty:
- **0% regrouping** (pAnyStart: 0) - All sums ≤ 9 (e.g., 3+4, 5+2)
- **50% regrouping** (pAnyStart: 0.5) - Mixed practice
- **100% regrouping** (pAnyStart: 1.0) - All problems require carrying
### 3. **Scaffolding Cycle Pattern**
For each new complexity level (digit count):
1. **Full scaffolding** - Ten-frames + carry boxes + place value colors
2. **Fade scaffolding** - Remove ten-frames, keep structure
3. **Increase complexity** - Add more digits, reintroduce scaffolding
### 4. **Mastery-Based Progression**
Students advance when they demonstrate:
- **Accuracy**: 85-95% correct (varies by difficulty)
- **Volume**: Minimum 15-20 problems attempted
- **Consistency**: Sustained performance over multiple worksheets
## Current Progression Path
### Phase 0: Foundation (Steps 0-1)
#### Step 0: Basic Single-Digit Addition
**Config**: 1 digit, 0% regrouping, minimal scaffolding
```typescript
pAnyStart: 0; // All sums ≤ 9
tenFrames: "never";
placeValueColors: "never";
carryBoxes: "never";
```
**Sample Problems**:
- 3 + 4 = 7
- 5 + 2 = 7
- 6 + 1 = 7
- 4 + 3 = 7
**Mastery**: 95% accuracy, 15 problems minimum
**Rationale**: Build foundational number sense and operation understanding without the cognitive load of regrouping.
#### Step 1: Mixed Single-Digit Practice
**Config**: 1 digit, 50% regrouping, conditional scaffolding
```typescript
pAnyStart: 0.5; // Half need carrying
tenFrames: "never";
placeValueColors: "whenRegrouping";
carryBoxes: "whenRegrouping";
```
**Sample Problems**:
- 3 + 4 = 7 (no carrying)
- **8 + 7 = 15** (carrying) ← Carry box shown
- 5 + 2 = 7 (no carrying)
- **9 + 6 = 15** (carrying) ← Carry box shown
**Mastery**: 90% accuracy, 20 problems minimum
**Rationale**: Gradual introduction to carrying in mixed context. Students see both types of problems and begin to recognize when carrying is needed.
### Phase 1: Single-Digit Carrying (Steps 2-3)
#### Step 2: Full Scaffolding (100% regrouping)
**Config**: 1 digit, 100% regrouping, full visual support
```typescript
pAnyStart: 1.0; // All require carrying
tenFrames: "whenRegrouping"; // ← TEN-FRAMES INTRODUCED
placeValueColors: "always";
carryBoxes: "whenRegrouping";
```
**Sample Problems**: (all show ten-frames)
- **8 + 7 = 15** 🔟🔟 (visual: 8 dots + 7 dots = full frame + 5 dots)
- **9 + 6 = 15** 🔟🔟
- **7 + 8 = 15** 🔟🔟
**Mastery**: 90% accuracy, 20 problems
**Rationale**: Ten-frames provide concrete visual representation of "making ten" (e.g., 8+7: take 2 from 7 to make 10, then add 5 more = 15). This supports the conceptual understanding of regrouping.
#### Step 3: Minimal Scaffolding
**Config**: 1 digit, 100% regrouping, ten-frames removed
```typescript
pAnyStart: 1.0;
tenFrames: "never"; // ← SCAFFOLDING FADED
placeValueColors: "always";
carryBoxes: "whenRegrouping";
```
**Mastery**: 90% accuracy, 20 problems
**Rationale**: Students internalize the carrying procedure and no longer need visual aids. The carry boxes remain to support procedural memory.
### Phase 2: Two-Digit Carrying (Steps 4-5)
**Same scaffolding cycle**, new digit range:
- Step 4: 2 digits, full scaffolding (ten-frames RETURN for new complexity)
- Step 5: 2 digits, minimal scaffolding (ten-frames fade)
**Sample Problems (Step 4)**:
- **27 + 18 = 45** (ones: 7+8=15, carrying to tens)
- **35 + 29 = 64** (ones: 5+9=14, carrying to tens)
**Rationale**: When complexity increases (more digits), scaffolding returns temporarily. This supports learning the new format while applying known carrying skills.
### Phase 3: Three-Digit Carrying (Steps 6-7)
**Same pattern**, 3 digits:
- Step 6: 3 digits, full scaffolding
- Step 7: 3 digits, minimal scaffolding
## Design Rationale
### Why Start with No Regrouping?
Research shows that:
1. **Cognitive Load**: Regrouping is a complex procedure. Students need to master basic addition first.
2. **Number Sense**: Understanding magnitude relationships (e.g., 7+3=10) supports later regrouping.
3. **Confidence**: Early success motivates continued practice.
4. **Diagnostic**: If students struggle with basic addition, regrouping will be impossible.
### Why Mixed Practice (50%)?
The transition step (Step 1) serves multiple purposes:
1. **Recognition Training**: Students learn to identify when carrying is needed
2. **Strategy Development**: Seeing both types helps students develop conditional reasoning
3. **Reduced Anxiety**: Not every problem is hard, maintaining motivation
4. **Real-World Realism**: Actual practice mixes problem types
### Why Ten-Frames?
Ten-frames are a research-validated manipulative that:
1. **Visualize Regrouping**: Clearly shows "making ten" (8 dots + 7 dots = full frame + 5)
2. **Support Subitizing**: Quick recognition of quantities up to 10
3. **Bridge Abstract/Concrete**: Connects symbolic notation to visual quantity
4. **Align with Base-10**: Naturally represents our number system
Example visualization:
```
8 + 7 = ?
[●●●●●] ← Top frame (carry to next place)
[●●●●●]
[●●○○○] ← Bottom frame (ones remaining)
[○○○○○]
8 dots + 7 dots = 10 dots (full frame) + 5 dots = 15
```
### Why Fade Scaffolding?
Scaffolding fading is essential for:
1. **Independence**: Students must eventually work without aids
2. **Efficiency**: Visual aids slow down calculation
3. **Transfer**: Skills must work in different contexts (tests, real life)
4. **Assessment**: Teacher needs to verify internalized understanding
## Future Extensions
### Multi-Carry Path (Not Yet Implemented)
Steps 8-13 would teach carrying in multiple places:
- 157 + 268 (carries in ones AND tens)
- 789 + 456 (carries in ones AND tens AND hundreds)
### Subtraction Path (Not Yet Implemented)
Similar progression for borrowing:
- Basic subtraction (no borrowing)
- Mixed practice
- Full borrowing with hints
- Fade borrowing hints
## Testing and Validation
When implementing changes to the progression path:
1. **Verify step numbering**: Sequential, 0-based, no gaps
2. **Check navigation**: Each step's next/previous IDs are correct
3. **Test mastery thresholds**: Reasonable accuracy requirements (85-95%)
4. **Validate configs**: All displayRules are defined, operator is correct
5. **User testing**: Have real students attempt the progression
## Implementation Notes
**File**: `src/app/create/worksheets/addition/progressionPath.ts`
**Key Constants**:
- `SINGLE_CARRY_PATH`: Array of ProgressionStep objects
- Each step has: id, stepNumber, technique, name, description, config, mastery criteria, navigation
**Helper Functions**:
- `getStepFromSliderValue()`: Map UI slider (0-100) to step
- `getSliderValueFromStep()`: Map step to slider position
- `findNearestStep()`: Match config to closest step
- `getStepById()`: Lookup step by ID
## References
- **Ten-Frames**: Van de Walle, J. A. (2004). Elementary and Middle School Mathematics
- **Scaffolding Fading**: Wood, D., Bruner, J. S., & Ross, G. (1976). The role of tutoring in problem solving
- **Mastery Learning**: Bloom, B. S. (1968). Learning for Mastery
- **Cognitive Load**: Sweller, J. (1988). Cognitive load during problem solving

View File

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

View File

@@ -1,36 +0,0 @@
# Resize Handle Tab Requirements
**CRITICAL: DO NOT MAKE THE ENTIRE HANDLE WIDE**
## What the user wants:
- A **thin 8px divider** for the resize handle (full height)
- A **small grab tab** (28px × 64px) that:
- Is centered vertically on the divider
- Extends to the RIGHT into the preview pane
- Has rounded corners on the top-right and bottom-right
- Has vertical knurled texture (ridges)
- Is DRAGGABLE
## What NOT to do:
❌ Make the entire PanelResizeHandle 36px wide
❌ Use clip-path to make a wide handle look thin
❌ Use pseudo-elements (\_after) that extend outside the parent bounds (they don't participate in drag events)
## The problem:
- PanelResizeHandle only responds to drag events on itself
- Child elements positioned absolutely outside the parent's bounds don't trigger parent drag
- Pseudo-elements extending outside don't work either
## Possible solutions to try:
1. Check if PanelResizeHandle accepts hitAreaMargins prop (from library source)
2. Use a custom drag overlay that forwards events to the handle
3. Accept that the tab is visual-only and keep the 8px handle draggable
## User frustration level: HIGH
- This is the **THIRD TIME** making the handle wide when asked for a small tab
- User explicitly said "FUUUUUCK, no wide drag handles!"

View File

@@ -1,571 +0,0 @@
# Saved Worksheets Feature - Auto-Persistence Architecture
> **📊 Implementation Status:** See `SAVED_WORKSHEETS_IMPLEMENTATION_STATUS.md` for current progress, alignment review, and roadmap.
## Overview
**Modern auto-persistence:** Changes save automatically in the background, like Google Docs or Figma. No explicit "Save" buttons.
**Core principle:** The quick anonymous workflow remains the default path. Auto-persistence is transparent and unobtrusive.
### User Experience
1. **Instant feedback**: Changes persist to localStorage immediately (zero latency)
2. **Background sync**: Changes sync to database after 5 seconds of inactivity
3. **Version history**: User can restore any previous state
4. **Smart library**: Worksheets auto-add when user shows commitment (generates PDF, names it)
5. **No save anxiety**: Everything is always saved
### Configuration
All behavior is tuneable via `src/config/persistence.ts` - see file for details.
**Key defaults:**
- Auto-save debounce: 5 seconds
- Add to library: On first PDF generation OR when named
- Version retention: Keep last 10 auto-saves, prune after 30 days
- Snapshots: Create permanent versions on PDF/share/upload (never pruned)
## User Stories
### Core Workflows
1. **Quick anonymous print** (unchanged)
- User visits `/create/worksheets/addition`
- Adjusts settings (auto-persisted to localStorage)
- Downloads PDF immediately
- No account, no explicit save, no friction
2. **Auto-save for reuse**
- User configures worksheet
- Settings auto-save to localStorage every change
- After 5 seconds of inactivity, syncs to database
- When they generate PDF, worksheet auto-adds to library
- Name is auto-generated: "Addition Worksheet - Jan 13, 2025"
3. **Version history**
- User realizes previous config was better
- Clicks "Version History" button
- Sees timeline of changes with preview
- Clicks "Restore" on previous version
- Config immediately restores, continues working
4. **Naming**
- Auto-generated names initially
- User clicks name to edit inline (like Google Docs)
- Name change saves immediately
- Named worksheets are findable in library
5. **Print tracking**
- User generates PDF with QR codes
- Each print creates immutable snapshot (version)
- Students scan QR code to upload
- Teacher sees which version they completed
## Data Model
### Table: `saved_worksheets`
Main library table - stores current state
```sql
CREATE TABLE saved_worksheets (
id TEXT PRIMARY KEY, -- UUID
user_id TEXT, -- Optional: User who created it
-- Naming
name TEXT NOT NULL, -- User-given or auto-generated name
description TEXT, -- Optional description
-- Type and config
worksheet_type TEXT NOT NULL, -- "addition", "subtraction", etc.
config JSON NOT NULL, -- Current worksheet config
-- Library metadata
tags TEXT, -- Comma-separated tags for filtering
is_favorite BOOLEAN DEFAULT FALSE, -- User starred it
is_template BOOLEAN DEFAULT FALSE, -- Featured template
is_public BOOLEAN DEFAULT FALSE, -- Can others discover it?
-- Auto-generated preview
thumbnail_url TEXT, -- Preview image
-- Engagement tracking
created_at INTEGER NOT NULL, -- Unix timestamp
updated_at INTEGER NOT NULL, -- Unix timestamp
last_viewed_at INTEGER, -- Last time user opened it
edit_count INTEGER DEFAULT 0, -- Number of edits
time_spent_ms INTEGER DEFAULT 0, -- Total time user spent editing
-- Version tracking
current_version_id TEXT, -- FK to latest worksheet_versions
version_count INTEGER DEFAULT 0, -- Total versions created
-- Usage tracking
pdf_generation_count INTEGER DEFAULT 0, -- How many PDFs generated
share_count INTEGER DEFAULT 0, -- How many times shared
upload_count INTEGER DEFAULT 0 -- How many student uploads
)
```
### Table: `worksheet_versions`
Immutable snapshots created at milestones
```sql
CREATE TABLE worksheet_versions (
id TEXT PRIMARY KEY, -- UUID
saved_worksheet_id TEXT NOT NULL, -- FK to saved_worksheets
-- Version metadata
version_number INTEGER NOT NULL, -- 1, 2, 3, etc. (auto-incremented)
version_type TEXT NOT NULL, -- 'auto-save' | 'snapshot' | 'manual'
-- Config snapshot
config JSON NOT NULL, -- Full worksheet config at this point
-- ACTUAL GENERATED PROBLEMS (for snapshots only)
problems JSON, -- Array of {a, b, operator, answer} - NULL for auto-saves
answer_key JSON, -- Pre-computed answer key - NULL for auto-saves
-- Timing
created_at INTEGER NOT NULL, -- When this version was created
created_by TEXT, -- User who created (if manual)
-- Context (why was this version created?)
created_reason TEXT, -- 'auto-save' | 'pdf-generate' | 'share' | 'upload' | 'manual-pin'
change_description TEXT, -- Optional: User notes ("Increased difficulty")
-- Metadata (for snapshots)
problem_count INTEGER, -- Total problems (NULL for auto-saves)
page_count INTEGER, -- Total pages (NULL for auto-saves)
FOREIGN KEY (saved_worksheet_id) REFERENCES saved_worksheets(id) ON DELETE CASCADE,
UNIQUE(saved_worksheet_id, version_number)
)
```
**Version Types:**
- **`auto-save`**: Background saves (every 5s after changes). No problems stored. Pruned after 30 days.
- **`snapshot`**: Milestone saves (PDF, share, upload). Full problems stored. Never pruned.
- **`manual`**: User clicked "Pin this version". Full problems stored. Never pruned.
### Table: `worksheet_instances`
Individual printed copies with QR codes
```sql
CREATE TABLE worksheet_instances (
id TEXT PRIMARY KEY, -- UUID (shown on printed worksheet)
saved_worksheet_id TEXT NOT NULL, -- FK to saved_worksheets
version_id TEXT NOT NULL, -- FK to worksheet_versions (snapshot)
version_number INTEGER NOT NULL, -- Denormalized for quick lookup
-- Print metadata
printed_at INTEGER NOT NULL, -- When PDF was generated
printed_by TEXT, -- User who generated it
-- Student assignment
student_name TEXT, -- Optional: Assigned student
class_id TEXT, -- Optional: Assigned class
due_date INTEGER, -- Optional: When it's due
-- Upload tracking
qr_code_url TEXT NOT NULL, -- URL students scan: /upload/[instance-id]
upload_count INTEGER DEFAULT 0, -- Number of uploads for this instance
FOREIGN KEY (saved_worksheet_id) REFERENCES saved_worksheets(id) ON DELETE CASCADE,
FOREIGN KEY (version_id) REFERENCES worksheet_versions(id) ON DELETE RESTRICT
)
```
### Extended: `worksheet_attempts`
Link uploaded work to specific instance
```sql
-- Add new column:
ALTER TABLE worksheet_attempts
ADD COLUMN worksheet_instance_id TEXT
REFERENCES worksheet_instances(id);
CREATE INDEX worksheet_attempts_instance_idx
ON worksheet_attempts(worksheet_instance_id);
```
## Auto-Persistence Flow
### 1. Initial Load
```
User visits /create/worksheets/addition
Check localStorage for unsaved work
↓ (if found)
Prompt: "Continue where you left off?" [Resume] [Start Fresh]
↓ (if resume)
Restore config from localStorage
Check database for saved worksheet (if ID in localStorage)
↓ (if found and newer)
Prompt: "Restore from cloud?" [Yes] [Keep Local]
```
### 2. Config Changes
```
User changes setting (e.g., difficulty slider)
IMMEDIATE: Save to localStorage (key: 'worksheet-config')
Debounced 5s: Sync to database
If worksheet has ID: UPDATE saved_worksheets + CREATE worksheet_versions (auto-save)
If no ID yet: Wait for trigger event
```
### 3. Trigger Events (Add to Library)
```
User generates PDF OR names worksheet
If no worksheet ID: CREATE saved_worksheets
CREATE worksheet_versions (snapshot type)
Store full problems + answer_key
UPDATE saved_worksheets.current_version_id
Show subtle toast: "✓ Saved to library"
```
### 4. Version History
```
User clicks "Version History"
Fetch worksheet_versions for this worksheet
Display timeline grouped by day
User clicks version → Show preview
User clicks "Restore" → UPDATE config, IMMEDIATE localStorage + DB save
```
## UI Changes
### Worksheet Editor (`/create/worksheets/addition`)
**Title bar changes:**
```
┌─────────────────────────────────────────────────────────┐
│ [Click to name] [Version History ▼] [⋮ More] │
└─────────────────────────────────────────────────────────┘
Inline editable title (like Google Docs):
- Shows auto-generated name initially
- Click to edit
- Auto-save on blur
- Placeholder: "Untitled Worksheet"
```
**Status indicator** (bottom right, subtle):
```
┌─────────────────┐
│ ✓ All changes │
│ saved │
└─────────────────┘
States:
- "✓ All changes saved" (default, gray)
- "⏳ Saving..." (blue, during debounce)
- "⚠ Offline - changes saved locally" (yellow, no network)
- "✗ Failed to save" (red, with retry button)
```
**Version History dropdown:**
```
┌─────────────────────────────────────────────────┐
│ Version History │
├─────────────────────────────────────────────────┤
│ TODAY │
│ ● 2:45 PM - Current (snapshot) │
│ Generated PDF [Preview] │
│ │
│ ○ 2:30 PM - Auto-save │
│ Changed difficulty to Advanced [Restore] │
│ │
│ YESTERDAY │
│ ○ 4:15 PM - Auto-save │
│ Added 3-digit problems [Restore] │
│ │
│ [Show older versions...] │
└─────────────────────────────────────────────────┘
Legend:
● Snapshot (permanent)
○ Auto-save (pruned after 30 days)
```
### ActionsSidebar Changes
**Remove explicit "Save" button** - replaced with smarter actions:
```
OLD (explicit save):
┌──────────────┐
│ 💾 Save │ ← DELETE THIS
└──────────────┘
NEW (milestone actions):
┌──────────────┐
│ 📄 Download │ ← Creates snapshot
│ 📱 Share │ ← Creates snapshot
│ ⬆️ Upload │ ← (unchanged)
└──────────────┘
Optional "Pin" action (advanced users):
┌──────────────┐
│ 📌 Pin │ ← Creates manual snapshot
└──────────────┘
Tooltip: "Save this exact version permanently"
```
### Library Page (`/worksheets/library`)
New page showing saved worksheets:
```
┌─────────────────────────────────────────────────────────┐
│ My Worksheets [🔍 Search] [⚙️ Sort] │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Addition │ │ Subtraction │ │
│ │ Worksheet │ │ Practice │ │
│ │ Jan 13, 2025 │ │ Jan 10, 2025 │ │
│ │ │ │ │ │
│ │ 3 versions │ │ 1 version │ │
│ │ 5 PDFs generated │ │ 2 PDFs generated │ │
│ │ │ │ │ │
│ │ [Open] [⋮] │ │ [Open] [⋮] │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ [Show archived] │
└─────────────────────────────────────────────────────────┘
```
## API Endpoints
### Auto-Save
```
PATCH /api/worksheets/saved/[id]/auto-save
Body: { config: WorksheetConfig }
Response: { saved: true, version_number: 5 }
Creates worksheet_versions entry with type='auto-save'
Updates saved_worksheets.config and updated_at
Respects PERSISTENCE_CONFIG.autoSave settings
```
### Snapshot Creation
```
POST /api/worksheets/saved/[id]/snapshot
Body: {
reason: 'pdf-generate' | 'share' | 'upload' | 'manual-pin',
config: WorksheetConfig,
problems: Problem[], // Full generated problems
answer_key: Answer[]
}
Response: { version_id: string, version_number: 6 }
Creates worksheet_versions entry with type='snapshot'
Stores full problems + answer_key
Never pruned
```
### Version History
```
GET /api/worksheets/saved/[id]/versions
Query: ?include_auto_saves=true&limit=20
Response: {
versions: [
{
id: 'v123',
version_number: 6,
type: 'snapshot',
created_at: 1705172400,
created_reason: 'pdf-generate',
config: {...},
has_problems: true
},
...
]
}
```
### Restore Version
```
POST /api/worksheets/saved/[id]/restore
Body: { version_id: 'v123' }
Response: { config: WorksheetConfig }
Updates saved_worksheets.config from version
Does NOT create new version (that happens on next change)
Returns config for client to apply
```
### Library List
```
GET /api/worksheets/saved
Query: ?page=1&limit=20&sort=updated_at&order=desc
Response: {
worksheets: [...],
total: 42,
page: 1,
has_more: true
}
```
## Implementation Phases
### Phase 1: Auto-Persistence Foundation ✓ DONE
- ✅ Database schema (saved_worksheets, worksheet_versions, worksheet_instances)
- ✅ Migration applied
- ✅ API endpoints created
-**PIVOT NEEDED**: Remove SaveWorksheetModal, implement auto-save
### Phase 2: Auto-Save System
1. **Create auto-save hook** (`useWorksheetAutoSave.ts`)
- localStorage immediate save
- Debounced DB sync
- Status indicator state
2. **Update worksheet editor**
- Remove "Save" button
- Add inline editable title
- Add status indicator (bottom right)
- Add version history dropdown
3. **Implement auto-save API**
- `PATCH /api/worksheets/saved/[id]/auto-save`
- Creates auto-save versions
- Prunes old auto-saves
4. **Update milestone actions**
- PDF download creates snapshot
- Share creates snapshot
- Upload creates snapshot
### Phase 3: Version History
1. **Create version history UI**
- Dropdown with timeline
- Preview modal
- Restore action
2. **Implement version APIs**
- `GET /api/worksheets/saved/[id]/versions`
- `POST /api/worksheets/saved/[id]/restore`
3. **Add version pruning job**
- Background task to prune old auto-saves
- Respects PERSISTENCE_CONFIG
### Phase 4: Library Page
1. **Create library page** (`/worksheets/library`)
- Grid of saved worksheets
- Search and filter
- Sort options
2. **Card interactions**
- Click to open in editor
- Context menu (rename, archive, delete)
- Duplicate action
3. **Smart library additions**
- Auto-add on first PDF
- Auto-add on naming
- Engagement time tracking
## Migration Strategy
**From current SaveWorksheetModal to auto-persistence:**
1. **Keep existing database schema** - it works for both models
2. **Remove UI components:**
- Delete `SaveWorksheetModal.tsx`
- Remove "Save" button from `ActionsSidebar.tsx`
3. **Add new UI components:**
- Create `useWorksheetAutoSave.ts` hook
- Create `VersionHistoryDropdown.tsx`
- Create `InlineEditableTitle.tsx`
- Create `SaveStatusIndicator.tsx`
4. **Update milestone actions:**
- PDF download calls snapshot API
- Share calls snapshot API
5. **Add library page:**
- Create `/worksheets/library/page.tsx`
## Testing Plan
1. **Auto-save timing**
- Change config → verify localStorage immediately
- Wait 5s → verify DB update
- Make rapid changes → verify only one DB call
2. **Version history**
- Make 10 changes → verify 10 auto-save versions
- Generate PDF → verify snapshot created
- Restore version → verify config updates
3. **Offline behavior**
- Disconnect network
- Make changes → verify localStorage works
- Reconnect → verify sync happens
4. **Version pruning**
- Create 20 auto-save versions
- Verify keeps last 10
- Verify snapshots never pruned
## Success Metrics
- **Zero user friction**: No "Save" dialogs, no anxiety
- **Fast feedback**: Config changes visible in < 16ms (one frame)
- **Reliable sync**: Background sync success rate > 99.9%
- **Library adoption**: > 50% of users who generate PDF have worksheets in library
- **Version usage**: > 10% of users restore a previous version
## Configuration Tuning
If behavior needs adjustment, edit `src/config/persistence.ts`:
```typescript
// Make auto-save more aggressive (save faster)
debounceMs: 2000 // Was 5000
// Keep more version history
maxVersionsToKeep: 20 // Was 10
// Require explicit library addition
library: {
autoAddOnFirstPdf: false, // Was true
autoAddOnNaming: false, // Was true
}
```

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,311 +0,0 @@
# Skill Configuration & Creation System
## Overview
Allow users to configure existing mastery skills and create custom skills using the Smart Mode's 2D difficulty editor (Regrouping Intensity × Scaffolding Level) plus digit range slider.
## Architecture
### 1. SkillConfigurationModal Component
A reusable modal that shows:
- **Digit Range Slider** (2-6 digits, matching current UI)
- **2D Difficulty Plot** (Regrouping Intensity × Scaffolding Level)
- **Make Easier/Harder buttons** (Challenge/Support/Both modes)
- **Overall Difficulty Slider**
- **Preview of selected difficulty** (shows pAnyStart, pAllStart, displayRules)
- **Skill Name input** (for custom skills)
- **Description input** (optional, for custom skills)
**Two modes:**
- **Edit Mode**: Configure existing skill (default or custom)
- **Create Mode**: Create new custom skill from scratch
### 2. MasteryModePanel Updates
Add two buttons:
- **"⚙️ Configure"** - Next to current skill name (edits current skill)
- **"+ Create Custom Skill"** - Below skill selector (creates new skill)
Show visual indicators:
- **Default skills**: Show as-is
- **Customized skills**: Show "⚙️ Custom" badge + "Reset to Default" button
- **User-created skills**: Show "✨ Custom" badge + "Delete" button
### 3. Database Schema
```sql
-- Fully user-created skills (new progression items)
CREATE TABLE custom_skills (
id TEXT PRIMARY KEY, -- Generated ID (e.g., 'custom-3d-moderate-regroup')
user_id TEXT NOT NULL,
operator TEXT NOT NULL, -- 'addition' | 'subtraction'
name TEXT NOT NULL, -- User-provided name
description TEXT, -- Optional description
digit_range TEXT NOT NULL, -- JSON: {min, max}
regrouping_config TEXT NOT NULL, -- JSON: {pAnyStart, pAllStart}
display_rules TEXT NOT NULL, -- JSON: displayRules object
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_custom_skills_user_operator ON custom_skills(user_id, operator);
-- Overrides for default skills (keeps skill ID, modifies config)
CREATE TABLE skill_customizations (
user_id TEXT NOT NULL,
skill_id TEXT NOT NULL, -- Original skill ID (e.g., 'sd-no-regroup')
operator TEXT NOT NULL, -- 'addition' | 'subtraction'
digit_range TEXT NOT NULL, -- JSON: {min, max}
regrouping_config TEXT NOT NULL, -- JSON: {pAnyStart, pAllStart}
display_rules TEXT NOT NULL, -- JSON: displayRules object
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, skill_id, operator),
FOREIGN KEY (user_id) REFERENCES users(id)
);
```
**Two tables because:**
- `custom_skills`: Fully user-created skills (new progression items)
- `skill_customizations`: Overrides for default skills (keeps skill ID, modifies config)
### 4. API Endpoints
```typescript
// Get all skills (defaults + custom + customizations)
GET /api/worksheets/skills?operator=addition
// Returns merged list with custom configs applied
// Response: { skills: Skill[], customizations: Map<SkillId, Config> }
// Create custom skill
POST /api/worksheets/skills/custom
{
name: string
description?: string
operator: 'addition' | 'subtraction'
digitRange: { min: number, max: number }
regroupingConfig: { pAnyStart: number, pAllStart: number }
displayRules: DisplayRules
}
// Returns: { id: string, ...skill }
// Update custom skill
PUT /api/worksheets/skills/custom/:id
{
name?: string
description?: string
digitRange?: { min: number, max: number }
regroupingConfig?: { pAnyStart: number, pAllStart: number }
displayRules?: DisplayRules
}
// Delete custom skill
DELETE /api/worksheets/skills/custom/:id
// Save skill customization (for default skills)
POST /api/worksheets/skills/:skillId/customize
{
operator: 'addition' | 'subtraction'
digitRange: { min: number, max: number }
regroupingConfig: { pAnyStart: number, pAllStart: number }
displayRules: DisplayRules
}
// Reset skill to default (delete customization)
DELETE /api/worksheets/skills/:skillId/customize?operator=addition
```
### 5. Skill Loading Logic
```typescript
interface SkillWithCustomization extends Skill {
isCustomized?: boolean; // Default skill that's been customized
isCustomCreated?: boolean; // User-created custom skill
originalConfig?: SkillConfig; // Original before customization
}
async function loadSkillsWithCustomizations(
operator: "addition" | "subtraction",
): Promise<SkillWithCustomization[]> {
// 1. Load default skills from static definitions
const defaultSkills = getSkillsByOperator(operator);
// 2. Load customizations for defaults
const customizationsResp = await fetch(
`/api/worksheets/skills/customizations?operator=${operator}`,
);
const { customizations } = await customizationsResp.json();
// 3. Load user-created custom skills
const customSkillsResp = await fetch(
`/api/worksheets/skills/custom?operator=${operator}`,
);
const { skills: customSkills } = await customSkillsResp.json();
// 4. Merge: apply customizations, append custom skills
const mergedDefaults = defaultSkills.map((skill) => {
const customization = customizations[skill.id];
if (customization) {
return {
...skill,
digitRange: customization.digitRange,
regroupingConfig: customization.regroupingConfig,
displayRules: customization.displayRules,
isCustomized: true,
originalConfig: {
digitRange: skill.digitRange,
regroupingConfig: skill.regroupingConfig,
displayRules: skill.displayRules,
},
};
}
return skill;
});
return [
...mergedDefaults,
...customSkills.map((skill) => ({
...skill,
isCustomCreated: true,
})),
];
}
```
### 6. UI Flows
#### Configuring an existing skill:
1. User clicks "⚙️ Configure" next to "2-digit, no regrouping"
2. Modal opens with:
- Title: "Configure Skill: 2-digit, no regrouping"
- Current skill's settings pre-loaded in 2D plot
- Digit slider shows current digitRange
- "Save" and "Cancel" buttons
3. User adjusts difficulty using plot/buttons/sliders
4. Click "Save" → Saves to `skill_customizations` table
5. Skill now shows "⚙️ Custom" badge
6. "Reset to Default" button appears next to Configure
#### Creating a custom skill:
1. User clicks "+ Create Custom Skill"
2. Modal opens with:
- Title: "Create Custom Skill"
- Blank name input (required)
- Optional description textarea
- 2D plot starts at Early Learner preset
- Digit slider starts at {min: 2, max: 2}
- "Create" and "Cancel" buttons
3. User enters name: "3-digit with moderate regrouping"
4. User adjusts difficulty using 2D plot
5. Click "Create" → Saves to `custom_skills` table
6. New skill appears in skill list with "✨ Custom" badge
7. Skill is selectable in mastery progression
8. User can configure/delete custom skills
#### Resetting a customization:
1. User clicks "Reset to Default" on customized skill
2. Confirmation modal: "Reset to default configuration?"
3. Click "Reset" → Deletes from `skill_customizations` table
4. Skill reverts to original default config
5. "⚙️ Custom" badge removed
#### Deleting a custom skill:
1. User clicks "🗑️ Delete" on custom skill
2. Confirmation modal: "Delete custom skill? This cannot be undone."
3. Click "Delete" → Removes from `custom_skills` table
4. If skill was currently selected, switches to first default skill
5. Skill removed from list
### 7. Component Structure
```
src/app/create/worksheets/components/
├── config-panel/
│ ├── MasteryModePanel.tsx (updated)
│ ├── SkillConfigurationModal.tsx (new)
│ ├── SkillSelector.tsx (extracted from MasteryModePanel)
│ └── CustomSkillBadge.tsx (new)
├── config-sidebar/
│ └── DifficultyTab.tsx (unchanged)
```
### 8. Key Benefits
**Pedagogically sound defaults** - Teachers can start with expert-designed progressions
**Customizable for advanced users** - Adjust any skill to match student needs
**Extendable** - Create additional practice steps in the progression
**Reversible** - Reset customizations to defaults anytime
**Per-user** - Each teacher gets their own custom skills/configs
**Unified UI** - Reuses the proven 2D difficulty editor from Smart Mode
**Non-destructive** - Original skills remain unchanged
**Progressive enhancement** - Works without customization, powerful with it
### 9. Implementation Order
1. ✅ Create database schema and migration
2. ✅ Create API endpoints (skills, customizations, custom skills)
3. ✅ Create SkillConfigurationModal component
4. ✅ Update MasteryModePanel with Configure/Create buttons
5. ✅ Add visual indicators (badges, reset/delete buttons)
6. ✅ Test skill loading/saving flow
7. ✅ Add confirmation modals for destructive actions
8. ✅ Polish UI/UX (tooltips, loading states, error handling)
### 10. Data Models
```typescript
interface SkillConfig {
digitRange: { min: number; max: number };
regroupingConfig: { pAnyStart: number; pAllStart: number };
displayRules: DisplayRules;
}
interface CustomSkill extends SkillConfig {
id: string;
userId: string;
operator: "addition" | "subtraction";
name: string;
description?: string;
createdAt: string;
updatedAt: string;
}
interface SkillCustomization extends SkillConfig {
userId: string;
skillId: string;
operator: "addition" | "subtraction";
updatedAt: string;
}
interface SkillWithMetadata extends Skill {
isCustomized?: boolean;
isCustomCreated?: boolean;
originalConfig?: SkillConfig;
}
```
### 11. Migration Strategy
Since this is a new feature, no data migration needed. However:
1. Ensure backward compatibility with existing `currentAdditionSkillId`/`currentSubtractionSkillId` in worksheet configs
2. Custom skill IDs should use a prefix (e.g., `custom-{uuid}`) to avoid collisions with default skill IDs
3. When loading a shared worksheet, ignore custom skill IDs (fall back to nearest default skill)
### 12. Future Enhancements
- **Import/Export**: Share custom skills with other teachers
- **Skill Templates**: Pre-made custom skill collections
- **Skill Analytics**: Track which skills students struggle with
- **Recommended Skills**: AI suggests next skill based on performance
- **Skill Ordering**: Drag-and-drop to reorder skill progression

View File

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

View File

@@ -1,233 +0,0 @@
# Subtraction Borrowing Frequency Bug Fix
**Date:** 2025-11-08
**Commit:** `98179fb8`
**Severity:** Critical - Feature completely broken for subtraction worksheets
## User Report
User noticed that even with regrouping frequency cranked up to 100% for all places (pAllStart = 1.0, pAnyStart = 1.0), subtraction worksheets were NOT generating many problems that require borrowing. This affected both:
- Manual mode (direct slider control)
- Smart difficulty mode (preset-based control)
## Root Cause Analysis
### The Bug
The `generateBothBorrow()` function in `problemGenerator.ts` (lines 424-458) used a **naive digit comparison** approach to count borrows:
```typescript
// OLD BUGGY CODE
for (let pos = 0; pos < maxPlaces; pos++) {
const digitM = getDigit(minuend, pos);
const digitS = getDigit(subtrahend, pos);
if (digitM < digitS) {
borrowCount++;
}
}
// Need at least 2 borrows
if (borrowCount >= 2) {
return [minuend, subtrahend];
}
```
### Why This Failed
#### Problem 1: Doesn't Handle Cascading Borrows
Example: `100 - 1`
- Ones: `0 < 1` → naive count = 1
- Tens: `0 < 0` → no increment
- Hundreds: `1 < 0` → no increment
- **Naive count: 1 borrow**
But the **actual subtraction algorithm** requires:
1. Borrow from hundreds to tens (hundreds becomes 0, tens becomes 10)
2. Borrow from tens to ones (tens becomes 9, ones becomes 10)
3. **Actual borrows: 2**
#### Problem 2: Impossible for 2-Digit Numbers
**Mathematical proof**: For 2-digit numbers where `minuend >= subtrahend`:
If `tensM < tensS`, then:
- Minuend = `tensM * 10 + onesM` where `tensM < tensS`
- Subtrahend = `tensS * 10 + onesS`
- Therefore: `minuend < tensS * 10 <= subtrahend`
- **Contradiction!** (violates `minuend >= subtrahend`)
**Result**: There are ZERO 2-digit subtraction problems where both `onesM < onesS` AND `tensM < tensS`.
I verified this empirically:
```bash
# Tested all 4095 valid 2-digit subtractions (10-99 where minuend >= subtrahend)
No borrowing: 2475 problems (60.4%)
Ones-only borrowing: 1620 problems (39.6%)
Both places borrow: 0 problems (0.0%)
```
### Impact on Users
When users set `pAllStart = 100%` with 2-digit subtraction:
1. Generator calculates: `pAll = 1.0, pAny = 1.0, pOnesOnly = 0, pNon = 0`
2. Picks category: `if (rand() < 1.0)` → always picks `'both'`
3. Calls `generateBothBorrow(rand, 2, 2)`
4. Function tries 5000 times to find a problem with `borrowCount >= 2`
5. **Never finds one** (mathematically impossible!)
6. Falls back to hardcoded `[93, 57]` which only has 1 borrow
7. Uniqueness check fails (same fallback every time)
8. After 50 retries, switches to random category
9. Eventually generates random mix of problems, NOT the 100% borrowing user requested
**Result**: User gets ~40% borrowing problems instead of 100%, violating their explicit configuration.
## The Fix
### 1. Correct Borrow Counting (`countBorrows()`)
Added new function that **simulates the actual subtraction algorithm**:
```typescript
function countBorrows(minuend: number, subtrahend: number): number {
const minuendDigits: number[] = [...] // Extract digits
let borrowCount = 0
for (let pos = 0; pos < maxPlaces; pos++) {
const digitM = minuendDigits[pos]
const digitS = getDigit(subtrahend, pos)
if (digitM < digitS) {
borrowCount++ // Count the borrow operation
// Find next non-zero digit to borrow from
let borrowPos = pos + 1
while (borrowPos < maxPlaces && minuendDigits[borrowPos] === 0) {
borrowCount++ // Borrowing across zero = additional borrow
borrowPos++
}
// Perform the actual borrow
minuendDigits[borrowPos]--
for (let p = borrowPos - 1; p > pos; p--) {
minuendDigits[p] = 9 // Intermediate zeros become 9
}
minuendDigits[pos] += 10
}
}
return borrowCount
}
```
**Test cases**:
- `52 - 17`: 1 borrow ✓
- `100 - 1`: 2 borrows ✓ (hundreds → tens → ones)
- `534 - 178`: 2 borrows ✓ (ones and tens both < subtrahend)
- `1000 - 1`: 3 borrows ✓ (across 3 zeros)
### 2. Handle 2-Digit Impossibility
Updated `generateBothBorrow()` to recognize when 2+ borrows are mathematically impossible:
```typescript
export function generateBothBorrow(
rand: () => number,
minDigits: number = 2,
maxDigits: number = 2,
): [number, number] {
// For 1-2 digit ranges, 2+ borrows are impossible
// Fall back to ones-only borrowing (maximum difficulty for 2-digit)
if (maxDigits <= 2) {
return generateOnesOnlyBorrow(rand, minDigits, maxDigits);
}
// For 3+ digits, use correct borrow counting
for (let i = 0; i < 5000; i++) {
// Favor higher digit counts for better chance of 2+ borrows
const digitsMinuend = randint(Math.max(minDigits, 3), maxDigits, rand);
const digitsSubtrahend = randint(Math.max(minDigits, 2), maxDigits, rand);
const minuend = generateNumber(digitsMinuend, rand);
const subtrahend = generateNumber(digitsSubtrahend, rand);
if (minuend <= subtrahend) continue;
const borrowCount = countBorrows(minuend, subtrahend);
if (borrowCount >= 2) {
return [minuend, subtrahend];
}
}
// Fallback: guaranteed 2+ borrow problem
return [534, 178]; // Changed from [93, 57] which only had 1 borrow!
}
```
### 3. Improved Fallback
Changed fallback from `[93, 57]` (1 borrow) to `[534, 178]` (2 borrows).
## Verification
After the fix, with `pAllStart = 100%` and `pAnyStart = 100%`:
**2-digit subtraction**:
- All problems have ones-only borrowing (maximum difficulty possible)
- Expected: ~100% problems with borrowing ✓
**3-digit subtraction**:
- Problems have 2+ actual borrow operations
- Includes cases like:
- `534 - 178` (ones and tens both borrow)
- `100 - 23` (borrow across zero in tens)
- `206 - 189` (cascading borrows)
## Lessons Learned
1. **Simulate, don't approximate**: The naive digit comparison seemed reasonable but missed critical edge cases (cascading borrows)
2. **Verify mathematical constraints**: We assumed 2-digit "both" problems existed without checking
3. **Test boundary conditions**: Should have tested with actual problem generation, not just assumed the logic was correct
4. **Document impossibilities**: Added clear comments about when "both" category is impossible vs. just rare
## Related Code
- `problemGenerator.ts`: Lines 417-514 (countBorrows, generateBothBorrow)
- `generateSubtractionProblems()`: Lines 515-596 (calls generateBothBorrow when pAll > threshold)
- `generateMixedProblems()`: Lines 566-607 (uses generateSubtractionProblems)
## Testing Recommendations
1. **Manual testing**:
- Set regrouping to 100% in manual mode
- Generate 2-digit subtraction worksheet
- Verify all problems require borrowing
2. **Automated testing**:
- Add unit tests for `countBorrows()` with edge cases
- Add tests for `generateBothBorrow()` across different digit ranges
- Verify distribution matches requested probabilities
3. **Visual inspection**:
- Generate worksheets at various difficulty levels
- Confirm borrowing frequency matches slider settings
- Test with digit ranges 1-5
---
**Status**: ✅ Fixed and committed
**User Impact**: High - Core feature now works as designed
**Regression Risk**: Low - Fix is localized to borrow counting logic

View File

@@ -1,720 +0,0 @@
# Theme Implementation Audit - Abaci.One Web Application
## Executive Summary
The Abaci.One web application is **currently dark-mode only** with:
- Hardcoded dark colors throughout (gray.900, rgba-based colors)
- No existing system-level theme switcher
- A `GameThemeContext` for arcade-specific game backgrounds only (not a general theme system)
- No `prefers-color-scheme` media query support
- Heavy use of Panda CSS for styling (excellent foundation for theming)
- Multiple pages and components with interdependent color schemes
**Implementation Difficulty: MODERATE** - Requires careful coordination of multiple systems but has good Panda CSS foundation.
---
## 1. Current Styling Architecture
### Panda CSS Configuration
**Location:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/panda.config.ts`
**Current State:**
- ✅ Has brand color tokens (blue scale: 50-900)
- ✅ Has soroban-specific tokens (wood, bead, bar colors)
- ✅ Uses color tokens for most UI elements
- ⚠️ NO theme variants defined (no light/dark modes in tokens)
- ⚠️ NO prefers-color-scheme media queries
**Key Tokens Defined:**
```typescript
colors: {
brand: {
50 - 900;
} // Sky blue scale
soroban: {
(wood, bead, inactive, bar);
} // Abacus-specific
}
```
### Global CSS
**Location:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/app/globals.css`
**Current State:**
- ✅ Clean, minimal CSS
- ✅ Only navigation height variables and keyframe definitions
- ✅ No hardcoded colors (good!)
- ⚠️ No theme variables
---
## 2. Page Inventory & Color Schemes
### Dark Mode Pages (Primary)
All pages use dark backgrounds. Here's the complete inventory:
#### Homepage (`/src/app/page.tsx`)
- **Background:** `gray.900` (hero section)
- **Text Colors:**
- Headings: White with gradient overlays (#fbbf24, #f59e0b - amber/yellow)
- Body text: `gray.400`, `purple.300`
- Links: White text
- **Special Elements:**
- Mini abacus with dark custom styles (white rgba fills/strokes)
- Skill cards with conditional gold/white borders
- Game cards with vibrant gradient backgrounds
**Line 49, 257:** `bg: 'gray.900'`
**Lines 83, 420-443:** Hardcoded linear-gradient colors with rgba values
#### Blog Pages (`/src/app/blog/page.tsx`, `/src/app/blog/[slug]/page.tsx`)
- **Background:** `gray.900` (main background)
- **Hero Gradient:** `linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)`
- **Text Colors:**
- Body text: `rgba(229, 231, 235, 0.95)` (light gray)
- Links: `rgba(147, 197, 253, 1)` (light blue)
- Code: `rgba(196, 181, 253, 1)` (light purple)
- Headings: `rgba(196, 181, 253, ...)` (purple variants)
- **Code Blocks:** `rgba(0, 0, 0, 0.4)` background
- **Blockquotes:** `rgba(139, 92, 246, ...)` (purple-tinted)
- **Tables:** Purple-tinted headers with dark rows
**Key Feature:** Blog content styling is HIGHLY detailed with nested selectors for markdown elements (h1-h3, p, ul, li, code, pre, blockquote, hr, table) - Lines 225-337
#### Guide Page (`/src/app/guide/page.tsx`)
- **Hero:** `linear-gradient(135deg, #667eea 0%, #764ba2 100%)` (purple gradient)
- **Tabs:** `bg: 'white'` with `borderColor: 'gray.200'` (LIGHT MODE!)
- **Tab Text:** `color: activeTab ? 'brand.600' : 'gray.600'` (LIGHT MODE!)
⚠️ **INCONSISTENCY ALERT:** Guide page uses light backgrounds while rest of site is dark
#### Arcade Games (`/src/app/arcade/**`)
- **Complement Race:**
- **GameDisplay:** `background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'` (purple gradient)
- **Pressure Gauge:** `background: 'rgba(255, 255, 255, 0.95)'` (nearly white!)
- **SVG Colors:** `fill: '#6366f1'` (indigo), `#7cb342` (green), `#d97757` (orange)
- **Text Colors:** `color: '#1f2937'` (dark gray) and `#6b7280` (medium gray)
- **Rithmomachia:**
- Varies by player (white/black pieces)
- **Guide Background:** `background: '#f3f4f6'` (very light gray - almost white!)
- Player badges: Light gradients vs dark backgrounds
- **Memory Quiz:** Not yet fully examined, likely dark-themed
#### Games Page (`/src/app/games/page.tsx`)
- Uses carousel with game cards
- Game cards have vibrant gradient backgrounds
- No explicit background color (inherits parent)
### Light Mode Pages (Inconsistent)
- **Guide Page:** Uses white backgrounds and light text (standalone anomaly)
- **Guide Components:** May have inline light styling
### Special Styling Cases
#### TutorialPlayer Component
- Has a `theme="dark"` prop (line 347 in page.tsx)
- Suggests theme support already exists in TutorialPlayer
- Background: `rgba(0, 0, 0, 0.4)` (dark transparent)
#### Arcade Games Styling
- **Complement Race:** Heavy use of hardcoded colors
- SVG fills: `#6366f1`, `#7cb342`, `#d97757`
- Text colors: `#1f2937`, `#6b7280`, `#3b82f6`, `#10b981`, `#f59e0b`
- Gradients: `linear-gradient(135deg, #3b82f6, #8b5cf6)`, `linear-gradient(135deg, #667eea 0%, #764ba2 100%)`
#### Rithmomachia Guide Page
- Uses `#f3f4f6` (light gray) background
- Contains SVG diagrams that may need color adjustment
---
## 3. Color Usage Patterns
### Hardcoded Colors Found
#### In `/src/app/page.tsx`:
- **Line 63:** `rgba(255, 255, 255, 0.15)` - subtle white overlay pattern
- **Line 83:** `#fbbf24`, `#f59e0b` - amber gradient for title
- **Lines 173-179:** `rgba(255, 255, 255, 0.3-0.4)` - abacus custom styles
- **Lines 420-443:** Multiple gold/white rgba values for skill cards
#### In `/src/app/blog/[slug]/page.tsx`:
- **Line 116:** `rgba(196, 181, 253, 0.8)` - purple link (back button)
- **Line 138:** `rgba(75, 85, 99, 0.5)` - dark border
- **Line 222:** `rgba(229, 231, 235, 0.95)` - body text
- **Lines 239-277:** Extensive markdown styling with hardcoded RGBA values for code, links, blockquotes
- **Line 319:** `rgba(139, 92, 246, 0.2)` - table header background
- **Line 332:** `rgba(75, 85, 99, 0.3)` - table border
#### In `/src/app/guide/page.tsx`:
- **Line 23:** `linear-gradient(135deg, #667eea 0%, #764ba2 100%)` - purple gradient
#### In Arcade Games:
- `/src/app/arcade/complement-race/components/GameDisplay.tsx`: Multiple colors like `#667eea`, `#3b82f6`, `#10b981`, `#f59e0b`
- `/src/app/arcade/complement-race/components/PassengerCard.tsx`: `#e8d4a0`, `#ff6b35`, `#ffaa35`
- `/src/app/arcade/complement-race/components/PressureGauge.tsx`: `#6b7280`, `#1f2937`
- `/src/app/arcade/complement-race/components/RaceTrack/CircularTrack.tsx`: `#7cb342` (green), `#d97757` (orange)
### Missing Dark Mode Equivalents
- **Blog Content:** All markdown styling assumes dark background
- Links: Light blue (`rgba(147, 197, 253, 1)`)
- Code backgrounds: Nearly black
- Need light mode: dark links, light code backgrounds
- **Arcade Games:** Heavy hardcoded colors without token abstraction
- Need systematic color mapping for all 10+ hardcoded hex values
- **SVG Graphics:**
- Hardcoded stroke/fill colors (e.g., `#7cb342`, `#d97757`)
- Will need color inversion or variable injection for light mode
---
## 4. Existing Theme Infrastructure
### GameThemeContext (Current)
**Location:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/contexts/GameThemeContext.tsx`
**Current Capability:**
```typescript
interface GameTheme {
gameName: string;
backgroundColor: string;
}
```
**Limitations:**
- ❌ Only handles arcade game backgrounds
- ❌ Not a general theme system
- ❌ Only one property (backgroundColor)
- ⚠️ Not wired to system preference detection
### TutorialPlayer Theme Prop
**Location:** `/src/app/page.tsx` line 347
```typescript
<TutorialPlayer theme="dark" />
```
**Implication:** TutorialPlayer component likely has light/dark theme support already
### Context Providers
**Location:** `/src/components/ClientProviders.tsx`
**Current Providers:**
- AbacusDisplayProvider
- QueryClientProvider
- NextIntlClientProvider
- ToastProvider
- UserProfileProvider
- GameModeProvider
- FullscreenProvider
- HomeHeroProvider
- MyAbacusProvider
**NOTE:** No theme provider exists (would need to add)
---
## 5. Special Considerations
### SVG Graphics
**Location:** `/public/blog/difficulty-examples/`, `/public/blog/ten-frame-examples/`
**Challenge:** These SVGs may have hardcoded colors:
- Need to either:
1. Generate light/dark variants of each SVG
2. Use CSS filters for color inversion (lossy)
3. Re-render SVGs with theme-aware colors
4. Make SVGs theme-aware with CSS variables
### Blog Post Content
**Challenge:** Blog HTML is generated from Markdown via `remark-html`
**Current Flow:**
1. Markdown → remark processes → HTML string
2. HTML inserted via `dangerouslySetInnerHTML`
3. Styled via nested CSS selectors in the page component
**Dark Mode Problem:** All nested selectors assume dark background
- Code blocks: dark backgrounds need light backgrounds
- Links: light blue needs dark blue
- Blockquotes: purple tint needs different tone
**Solution Options:**
1. Define light/dark CSS selector variants in panda
2. Use CSS custom properties inside the CSS-in-JS
3. Generate two HTML versions (light/dark)
4. Add a global stylesheet with theme-aware variables
### Arcade Games with Custom Backgrounds
**Challenge:** Games have custom gradient backgrounds and SVG renders
**Components Affected:**
- Complement Race: Purple gradient + multiple SVG colors
- Rithmomachia: Player-specific backgrounds (white/black)
- Memory Quiz: Unknown (needs inspection)
**SVG Examples:**
- `/src/app/arcade/complement-race/components/RaceTrack/CircularTrack.tsx`
- Green path: `#7cb342`
- Orange path: `#d97757`
- Inline SVG with hardcoded fills
### Inline Styles vs CSS Classes
**Finding:** Heavy use of inline Panda `css()` function (good for JS-driven theming)
**Advantage:** Most colors are in code that can be updated programmatically
**Disadvantage:** Requires recompilation/rebuilding for CSS-in-JS changes
### Responsive Design
**Current:** Uses Panda CSS responsive breakpoints extensively
- No responsive theme switching needed (good news)
---
## 6. Component-Level Color Dependencies
### Components with Multiple Color Requirements
#### Skill Cards (Homepage)
- **Background:** Conditional gradient (gold selected vs white unselected)
- **Border:** Gold vs white
- **Text:** White (fixed)
- **Shadow:** Gold vs black
- **Needs:** 5-6 color variants for light/dark
#### Game Cards (Homepage)
- **Gradient Background:** Unique per game
- **Overlay Gradient:** `rgba(0,0,0,...)` (works for both themes if adjusted)
- **Text:** White (needs light mode variant)
#### Blog Markdown
- **Links:** Currently light blue
- **Code blocks:** Currently dark
- **Blockquotes:** Purple-tinted borders
- **Tables:** 4-5 color variants needed
#### Arcade Games
- **Complement Race:** 10+ hardcoded colors
- **Rithmomachia:** Player-based colors (may conflict with light mode)
- **SVG Elements:** Hardcoded stroke/fill
---
## 7. Files Requiring Modification
### High Priority (Core Styling)
1. **`panda.config.ts`** - Add theme tokens and color variants
2. **`globals.css`** - Add CSS custom properties for theme
3. **`ClientProviders.tsx`** - Add theme provider
4. **`AppNavBar.tsx`** - Theme-aware styling
5. **`src/app/layout.tsx`** - Detect system preference, set initial theme
6. **`src/app/page.tsx`** - Update hero and skill cards
### Medium Priority (Pages)
7. **`src/app/blog/page.tsx`** - Update blog index styling
8. **`src/app/blog/[slug]/page.tsx`** - Update blog post markdown styling (COMPLEX)
9. **`src/app/blog/layout.tsx`** - If needed for blog-specific overrides
10. **`src/app/guide/page.tsx`** - Refactor (currently has light mode, make consistent)
11. **`src/app/games/page.tsx`** - Update styling
### High Priority (Arcade)
12. **`src/arcade-games/complement-race/components/GameDisplay.tsx`** - 10+ color replacements
13. **`src/arcade-games/complement-race/components/PressureGauge.tsx`** - Background/text colors
14. **`src/arcade-games/complement-race/components/PassengerCard.tsx`** - Custom colors
15. **`src/arcade-games/complement-race/components/RaceTrack/CircularTrack.tsx`** - SVG colors
16. **`src/arcade-games/complement-race/components/RaceTrack/GhostTrain.tsx`** - SVG colors
### Medium Priority (Contexts & Components)
17. **`src/contexts/GameThemeContext.tsx`** - Extend to general theme system
18. **`src/components/HomeBlogSection.tsx`** - Blog preview card styling
19. **`src/components/TutorialPlayer.tsx`** - Ensure theme prop works correctly
20. **`src/components/tutorial/DecompositionWithReasons.tsx`** - If uses colors
21. **`src/components/matching/** - Any color dependencies
### Low Priority (Tests/Demo)
22. Test pages in `/src/app/test-*` - Update if needed
23. Storybook stories - Update examples
---
## 8. Hardcoded Color Reference Table
| Color | Hex | RGBA | Current Use | Light Mode Option |
| ------ | ------- | ------------------------ | ------------------------ | --------------------- |
| Amber | #fbbf24 | - | Gradient titles, accents | Keep same? |
| Amber | #f59e0b | - | Gradient titles, accents | Keep same? |
| Purple | #667eea | - | Game gradients | Adjust |
| Purple | #764ba2 | - | Game gradients | Adjust |
| Indigo | #6366f1 | - | Train color | Adjust |
| Blue | #3b82f6 | - | Text/UI | Darken for light mode |
| Blue | #0284c7 | - | Brand (token) | Keep |
| Green | #7cb342 | - | Track path | Adjust |
| Orange | #d97757 | - | Track path | Darken |
| Amber | - | rgba(250, 204, 21, 0.X) | Skill card highlights | Adjust |
| White | - | rgba(255, 255, 255, 0.X) | Patterns, strokes | Invert to black |
| Black | - | rgba(0, 0, 0, 0.X) | Backgrounds, overlays | Invert to white |
| Purple | - | rgba(139, 92, 246, 0.X) | Blog accents | Adjust |
| Purple | - | rgba(196, 181, 253, 0.X) | Blog headings | Darken |
| Gray | - | rgba(209, 213, 219, 0.X) | Body text | Lighten to dark |
| Gray | - | rgba(229, 231, 235, 0.X) | Light text | Invert |
| Gray | - | rgba(75, 85, 99, 0.X) | Borders | Invert |
---
## 9. Potential Breaking Changes
### Layout/Overflow Issues
1. **White text on dark backgrounds** - When inverted for light mode:
- Page titles (white → black)
- Body text (light gray → dark gray)
- May need brand color adjustments
### Game Balance
2. **Arcade Game Colors** - Some colors may have semantic meaning:
- Green/orange track = visual clarity (may need adjustment)
- Train colors = player identification
- Need to test color contrast in light mode
### Blog Content
3. **External SVG Graphics** - May not adapt to light mode:
- Difficulty examples show code/diagrams
- May need PNG/PNG alternatives or CSS filters
### Navigation
4. **AppNavBar** - Currently dark with light text
- Light mode version may need inverse
- Hamburger menu styling
- Dropdown menus
---
## 10. Approach Recommendations
### Phased Implementation Strategy
**Phase 1: Foundation (Week 1)**
- Add theme tokens to `panda.config.ts`
- Create ThemeProvider context
- Add system preference detection
- Update `globals.css` with CSS custom properties
- Implement theme toggle in AppNavBar
**Phase 2: Core Pages (Week 1-2)**
- Homepage (hero, skill cards, game cards)
- Guide page (refactor from light-mode inconsistency)
- Blog index and list
- Basic arcade game components
**Phase 3: Complex Content (Week 2-3)**
- Blog post markdown styling (requires careful nested selector work)
- Arcade games (Complement Race, Rithmomachia)
- SVG graphics (evaluate color mapping strategy)
**Phase 4: Polish & Testing (Week 3)**
- Component testing
- Visual regression testing
- Contrast/accessibility audit
- User feedback
### Recommended Color Palette Structure
```typescript
// Light Mode
{
text: {
primary: '#1f2937', // Dark gray
secondary: '#6b7280', // Medium gray
tertiary: '#9ca3af', // Light gray
inverted: '#ffffff', // White for dark backgrounds
},
background: {
primary: '#ffffff', // White
secondary: '#f3f4f6', // Off-white
tertiary: '#e5e7eb', // Light gray
overlay: 'rgba(0, 0, 0, 0.05)',
},
accent: {
gold: '#f59e0b', // Keep amber
purple: '#7c3aed', // Dark purple
blue: '#2563eb', // Dark blue
},
}
// Dark Mode (current)
{
text: {
primary: '#e5e7eb', // Light gray
secondary: '#d1d5db', // Medium-light gray
tertiary: '#9ca3af', // Medium gray
inverted: '#1f2937', // Dark for light backgrounds
},
background: {
primary: '#111827', // gray.900
secondary: '#1f2937', // gray.800
tertiary: '#374151', // gray.700
overlay: 'rgba(255, 255, 255, 0.05)',
},
accent: {
gold: '#fbbf24', // Light amber
purple: '#c4b5fd', // Light purple
blue: '#93c5fd', // Light blue
},
}
```
### CSS Custom Properties Approach
```css
:root[data-theme="light"] {
--color-text-primary: #1f2937;
--color-bg-primary: #ffffff;
--color-accent-gold: #f59e0b;
/* ... more properties */
}
:root[data-theme="dark"],
:root {
--color-text-primary: #e5e7eb;
--color-bg-primary: #111827;
--color-accent-gold: #fbbf24;
/* ... more properties */
}
```
---
## 11. Accessibility Considerations
### Color Contrast
- **WCAG AA minimum:** 4.5:1 for normal text, 3:1 for large text
- **Current dark mode:** Generally good contrast
- **Light mode risk:** Some colors may not meet 4.5:1
- Purple headings may be too light on white
- Gray text may not be dark enough
### Recommendations
1. Run contrast checker on all color combinations
2. Test with ColorOracle color-blindness simulator
3. Ensure brand colors (amber, purple) meet WCAG AA in both modes
4. Consider reducing gold opacity in light mode
### Motion/Animation
- Particle effects and gradients should respect `prefers-reduced-motion`
- Currently using animations in panda.config.ts without media query check
---
## 12. Testing Strategy
### Visual Testing
- Screenshot comparison tool (Percy, Chromatic)
- Manual review of all pages in light/dark
- Test on multiple browsers
### Color Contrast Testing
- WAVE WebAIM contrast checker
- Axe DevTools
- Manual ColorOracle testing
### Component Testing
- Unit tests for theme switching logic
- Integration tests for context propagation
- E2E tests for theme persistence
### User Testing
- Usability testing with theme toggle
- Feedback on color choices
- Accessibility feedback from screen reader users
---
## 13. Implementation Complexity Matrix
| Component | Dark→Light Complexity | Reason |
| --------------- | --------------------- | ----------------------------------------------- |
| Homepage | MEDIUM | Skill cards, gradients need careful adjustment |
| Blog Index | LOW | Straightforward color inversions |
| Blog Posts | HIGH | Complex markdown selectors, many color variants |
| Guide Page | LOW | Mostly refactoring (already has light mode) |
| Arcade Games | HIGH | 10+ hardcoded colors per game, SVG issues |
| Complement Race | HIGH | Complex SVG, particle effects, gradients |
| Rithmomachia | MEDIUM | Player-based colors may need semantic rethink |
| Navigation | MEDIUM | Multiple menus, dropdowns need testing |
| SVG Graphics | MEDIUM-HIGH | Depends on CSS filter strategy |
| Tutorial Player | LOW | Already has theme prop (verify works) |
| Components | LOW-MEDIUM | Most use Panda CSS, easy to theme |
---
## 14. Risk Assessment
### High Risk Areas
1. **Blog Post Markdown Rendering** - Complex nested CSS selectors, many color variants
2. **Arcade Game SVGs** - Hardcoded fills/strokes, may not respond to CSS
3. **Color Contrast in Light Mode** - Some purple/gold combinations may fail WCAG
4. **System Preference Detection** - Browser APIs differ, SSR hydration issues
### Medium Risk Areas
1. **Theme Persistence** - localStorage, cookie handling, sync across tabs
2. **Performance** - Theme switching shouldn't cause layout shift
3. **Component Library** - Third-party components (Radix UI) may need theme updates
4. **Animation Color Interactions** - Particle effects, gradients may look odd in light mode
### Low Risk Areas
1. **Text Content** - No changes needed
2. **Layout/Structure** - Theme doesn't affect grid/flex
3. **Interactions** - Click handlers, form validation unchanged
4. **API Integration** - Backend unaffected
---
## 15. File Location Quick Reference
### Configuration
- Panda Config: `/panda.config.ts`
- Global CSS: `/src/app/globals.css`
- Theme Context: `/src/contexts/GameThemeContext.tsx` (needs extension)
### Core Pages
- Homepage: `/src/app/page.tsx` (49, 257, 310, 420-443)
- Blog: `/src/app/blog/page.tsx`, `/src/app/blog/[slug]/page.tsx` (225-337)
- Guide: `/src/app/guide/page.tsx` (23, 57-79)
- Games: `/src/app/games/page.tsx`
### Arcade Games
- Complement Race Display: `/src/app/arcade/complement-race/components/GameDisplay.tsx`
- Pressure Gauge: `/src/app/arcade/complement-race/components/PressureGauge.tsx`
- Passenger Card: `/src/app/arcade/complement-race/components/PassengerCard.tsx`
- Race Track: `/src/app/arcade/complement-race/components/RaceTrack/CircularTrack.tsx`
### Client Setup
- Providers: `/src/components/ClientProviders.tsx`
- Layout: `/src/app/layout.tsx`
- NavBar: `/src/components/AppNavBar.tsx`
---
## 16. Additional Exploration Needed
Before implementation, verify:
1. **TutorialPlayer Light Mode Support**
- Check if `theme="dark"` prop has light equivalent
- Verify theme prop integration
- Location: `/src/components/tutorial/TutorialPlayer.tsx`
2. **Memory Quiz Styling**
- Color scheme not examined
- Location: `/src/app/arcade/memory-quiz/**`
3. **Additional Arcade Games**
- Card Sorting, Matching Games
- Check for hardcoded colors
4. **Third-Party Components**
- Radix UI dropdown, tooltip, dialog theming
- Embla carousel colors
- Toast notifications theming
5. **CSS-in-JS Framework Capabilities**
- Panda CSS theme support
- CSS custom property interpolation
- Performance implications
---
## Summary Table
| Aspect | Current State | Status |
| ------------------------------- | -------------------------------------- | ------ |
| **Overall Theme** | Dark mode only | ❌ |
| **Color Tokens** | Partial (brand + soroban) | ⚠️ |
| **Theme Variants** | None | ❌ |
| **System Preference Detection** | None | ❌ |
| **Theme Provider** | None (has GameThemeContext for arcade) | ⚠️ |
| **Hardcoded Colors** | ~30+ instances | ❌ |
| **CSS Custom Properties** | Navigation heights only | ⚠️ |
| **Light Mode Pages** | Guide page (inconsistent) | ⚠️ |
| **Accessibility Audit** | Not done | ❌ |
| **Test Coverage** | Likely none for theming | ❌ |
| **Documentation** | `.claude/GAME_THEMES.md` exists | ✅ |
**Overall Readiness: READY TO IMPLEMENT**
- Foundation is solid (Panda CSS)
- Clear color pattern usage
- No blocking architecture issues
- Requires systematic, careful implementation

View File

@@ -1,220 +0,0 @@
================================================================================
THEME IMPLEMENTATION AUDIT - SUMMARY
================================================================================
PROJECT: Abaci.One Web Application
AUDIT DATE: 2025-11-07
SCOPE: Full site light/dark mode planning
DIFFICULTY: MODERATE
TIME ESTIMATE: 2-3 weeks (phased implementation)
================================================================================
KEY FINDINGS
================================================================================
CURRENT STATE:
✅ Excellent Panda CSS foundation for theming
✅ Consistent use of CSS-in-JS (css() function) for styling
✅ Clean global CSS with minimal hardcoded values
❌ Completely dark-mode only (no light mode infrastructure)
❌ ~30+ hardcoded color values scattered throughout
❌ No system preference detection (prefers-color-scheme)
❌ No theme provider/context (except GameThemeContext for arcade only)
PAGES AFFECTED:
- Homepage: Hero section, skill cards, game cards (MEDIUM complexity)
- Blog Index: Straightforward (LOW complexity)
- Blog Posts: Complex markdown styling with 10+ color variants (HIGH complexity)
- Guide Page: Currently INCONSISTENT (has light mode but isolated)
- Arcade Games: 10+ hardcoded colors per game, SVG issues (HIGH complexity)
- Games Page: Carousel styling (LOW complexity)
- Navigation: Multiple components, dropdowns (MEDIUM complexity)
HARDCODED COLORS FOUND:
~30+ instances across:
- rgba() colors for dark mode overlays and text
- Hex colors for game components and SVGs
- Linear gradients with hardcoded values
- Blog markdown element styling
- Arcade game visuals (train, tracks, gauges, cards)
FILES REQUIRING CHANGES: 23+ files across all major sections
================================================================================
PHASED IMPLEMENTATION PLAN
================================================================================
PHASE 1: FOUNDATION (1 week)
✓ Extend panda.config.ts with light/dark color tokens
✓ Create ThemeProvider context (extend GameThemeContext)
✓ Add CSS custom properties to globals.css
✓ Implement system preference detection in layout.tsx
✓ Add theme toggle to AppNavBar
PHASE 2: CORE PAGES (1-2 weeks)
✓ Homepage (hero, skill cards, game cards)
✓ Guide page (fix light-mode inconsistency)
✓ Blog index and list
✓ Games page carousel
PHASE 3: COMPLEX CONTENT (1-2 weeks)
✓ Blog post markdown styling (HIGH EFFORT)
✓ Arcade game components (Complement Race, Rithmomachia)
✓ SVG graphics strategy
PHASE 4: TESTING & POLISH (1 week)
✓ Visual regression testing
✓ Accessibility audit (WCAG contrast)
✓ Cross-browser testing
✓ User feedback
================================================================================
HIGH-RISK AREAS (Require Extra Attention)
================================================================================
1. BLOG POST MARKDOWN (Highest Risk)
- Complex nested CSS selectors for h1-h3, p, ul, li, code, pre, blockquote, table
- ALL markdown styling assumes dark background
- Lines 225-337 in /src/app/blog/[slug]/page.tsx
- Solution: Use CSS custom properties or dual selector strategy
2. ARCADE GAME SVGs (High Risk)
- Inline SVG with hardcoded stroke/fill colors
- Examples: #7cb342 (green), #d97757 (orange), #6366f1 (indigo)
- Solution: CSS filter approach or re-render with theme-aware colors
3. COLOR CONTRAST IN LIGHT MODE (Medium Risk)
- Purple/gold accent colors may not meet WCAG AA (4.5:1)
- Need contrast testing for all combinations
- Especially purple headings on light backgrounds
4. SYSTEM PREFERENCE DETECTION (Medium Risk)
- SSR hydration issues (server dark mode, client light mode)
- localStorage sync across tabs
- Browser API compatibility
================================================================================
QUICK REFERENCE: FILES TO MODIFY
================================================================================
CRITICAL (Foundation):
□ panda.config.ts - Add theme tokens
□ src/app/globals.css - Add CSS custom properties
□ src/contexts/GameThemeContext.tsx - Extend to general theme system
□ src/components/ClientProviders.tsx - Add theme provider
□ src/app/layout.tsx - Detect system preference, set initial theme
□ src/components/AppNavBar.tsx - Add theme toggle
MAJOR PAGES:
□ src/app/page.tsx - Homepage hero and skill cards
□ src/app/blog/page.tsx - Blog index
□ src/app/blog/[slug]/page.tsx - Blog post markdown (COMPLEX)
□ src/app/guide/page.tsx - Fix light-mode inconsistency
□ src/app/games/page.tsx - Games page styling
ARCADE GAMES:
□ src/arcade-games/complement-race/components/GameDisplay.tsx
□ src/arcade-games/complement-race/components/PressureGauge.tsx
□ src/arcade-games/complement-race/components/PassengerCard.tsx
□ src/arcade-games/complement-race/components/RaceTrack/CircularTrack.tsx
□ src/arcade-games/complement-race/components/RaceTrack/GhostTrain.tsx
COMPONENTS:
□ src/components/HomeBlogSection.tsx
□ src/components/tutorial/TutorialPlayer.tsx - Verify theme prop works
================================================================================
COLOR PALETTE RECOMMENDATION
================================================================================
LIGHT MODE:
Text Primary: #1f2937 (Dark gray)
Text Secondary: #6b7280 (Medium gray)
Background: #ffffff (White)
Accent Gold: #f59e0b (Keep same as dark)
Accent Purple: #7c3aed (Dark purple)
Accent Blue: #2563eb (Dark blue)
DARK MODE (Current):
Text Primary: #e5e7eb (Light gray)
Text Secondary: #d1d5db (Medium-light gray)
Background: #111827 (gray.900)
Accent Gold: #fbbf24 (Light amber)
Accent Purple: #c4b5fd (Light purple)
Accent Blue: #93c5fd (Light blue)
================================================================================
TESTING CHECKLIST
================================================================================
Visual Testing:
□ All pages render correctly in light mode
□ All pages render correctly in dark mode
□ Theme toggle works and persists
□ System preference detected correctly
□ No layout shift on theme switch
Accessibility:
□ WCAG AA contrast (4.5:1) for all text
□ WCAG AA contrast (3:1) for large text
□ Test with ColorOracle (color-blindness simulator)
□ Screen reader compatibility
□ Reduced motion support
Browser Support:
□ Chrome/Edge (latest)
□ Firefox (latest)
□ Safari (latest)
□ Mobile browsers
□ SSR hydration (no mismatch)
Component Testing:
□ Blog markdown elements (all tags)
□ Arcade game visuals (gameplay intact)
□ Navigation menus (hover states)
□ Form inputs (dark/light variants)
□ SVG graphics (all variants)
================================================================================
KEY STATISTICS
================================================================================
Total Pages: 14 main pages
Dark Mode Only: 100% of pages
Hardcoded Colors: ~30+ instances
Files to Modify: 23+ files
Blog Markdown Elements: 10+ (h1, h2, h3, p, ul, li, code, pre, blockquote, table)
Arcade Game Colors: 10+ per game
SVG Graphics: 13 files in /public/blog/
Estimated Complexity: MODERATE
Estimated Time: 2-3 weeks (phased)
================================================================================
FULL AUDIT DOCUMENT
================================================================================
Location: /Users/antialias/projects/soroban-abacus-flashcards/apps/web/.claude/THEME_AUDIT.md
Contains:
✓ Detailed analysis of all pages and components
✓ Line-by-line color mapping
✓ Hardcoded color reference table
✓ Risk assessment
✓ Implementation complexity matrix
✓ Accessibility considerations
✓ Testing strategy
✓ File location quick reference
✓ Additional exploration needed
================================================================================
NEXT STEPS
================================================================================
1. Review audit document thoroughly
2. Verify findings (especially TutorialPlayer theme support)
3. Check 3rd-party component theming (Radix UI, Embla carousel)
4. Design color palette and get design approval
5. Begin Phase 1: Foundation work
6. Proceed with phased implementation per schedule
================================================================================

View File

@@ -1,360 +0,0 @@
# Theme Implementation Checklist
## Pre-Implementation Verification
Before starting implementation, verify these items:
- [ ] Read full THEME_AUDIT.md document
- [ ] Review THEME_AUDIT_SUMMARY.txt for quick overview
- [ ] Verify TutorialPlayer has `theme="light"` prop support
- [ ] Check Radix UI theming capabilities
- [ ] Check Embla carousel color customization
- [ ] Review panda.config.ts tokens thoroughly
- [ ] Get design approval on color palette
- [ ] Plan SVG graphics strategy (filters vs variants vs re-render)
## Phase 1: Foundation (Week 1)
### Configuration Files
- [ ] Extend `panda.config.ts` with light/dark color tokens
- [ ] Add theme tokens object with light/dark variants
- [ ] Define all color scales (text, background, accent)
- [ ] Add semantic color names (primary, secondary, etc.)
- [ ] Test token generation: `npm run build`
### Global Styling
- [ ] Update `src/app/globals.css`
- [ ] Add CSS custom properties for theme colors
- [ ] Add `:root[data-theme="light"]` selector
- [ ] Add `:root[data-theme="dark"]` selector
- [ ] Ensure animation/motion properties work with both themes
### Theme Provider
- [ ] Create/extend theme provider in `src/contexts/`
- [ ] Extend GameThemeContext or create new ThemeProvider
- [ ] Add theme detection (system preference, localStorage)
- [ ] Add theme toggle function
- [ ] Handle SSR hydration properly
- [ ] Add `data-theme` attribute to root element
### Integration
- [ ] Update `src/components/ClientProviders.tsx`
- [ ] Add theme provider to provider stack
- [ ] Ensure proper provider ordering
- [ ] Update `src/app/layout.tsx`
- [ ] Add system preference detection
- [ ] Set initial theme from localStorage or system
- [ ] Avoid hydration mismatch
- [ ] Update `src/components/AppNavBar.tsx`
- [ ] Add theme toggle button
- [ ] Update NavBar styling for both themes
- [ ] Test dropdown menus in both modes
- [ ] Test hamburger menu in both modes
### Testing Phase 1
- [ ] Theme detection works (system preference)
- [ ] Theme toggle works and persists
- [ ] No console errors in browser dev tools
- [ ] TypeScript compiles: `npm run type-check`
- [ ] Linting passes: `npm run lint`
## Phase 2: Core Pages (Week 1-2)
### Homepage
- [ ] Update `src/app/page.tsx`
- [ ] Update hero section styling
- [ ] Update skill card styling (gradients, borders)
- [ ] Update game card styling
- [ ] Update mini abacus dark/light styles
- [ ] Test all components render correctly
- [ ] Update `src/components/HomeBlogSection.tsx`
- [ ] Featured posts styling
- [ ] Card backgrounds and borders
- [ ] Text colors and contrast
### Blog Pages
- [ ] Update `src/app/blog/page.tsx`
- [ ] Blog index page styling
- [ ] Featured posts carousel
- [ ] Category filters (if applicable)
- [ ] Update `src/app/guide/page.tsx`
- [ ] Fix light-mode inconsistency
- [ ] Hero section gradient (light/dark variant)
- [ ] Tab styling (was using white background)
- [ ] Component styling
- [ ] Update `src/app/games/page.tsx`
- [ ] Games carousel styling
- [ ] Player carousel styling
- [ ] Game card styling
### Components
- [ ] Update `src/components/TutorialPlayer.tsx`
- [ ] Verify `theme="dark"` prop works
- [ ] Add `theme="light"` support if needed
- [ ] Test tutorial display in both themes
### Testing Phase 2
- [ ] All pages render in light mode
- [ ] All pages render in dark mode
- [ ] Text contrast passes WCAG AA (4.5:1)
- [ ] No layout shifts on theme change
- [ ] Responsive design works in both themes
- [ ] Run quality checks: `npm run pre-commit`
## Phase 3: Complex Content (Week 2-3)
### Blog Post Markdown (HIGHEST PRIORITY - COMPLEX)
- [ ] Update `src/app/blog/[slug]/page.tsx` markdown styling
- [ ] Update h1, h2, h3 styling for light mode
- [ ] Update paragraph text colors
- [ ] Update link colors (light blue → dark blue)
- [ ] Update code block styling (dark bg → light bg)
- [ ] Update pre/code colors
- [ ] Update blockquote styling
- [ ] Update table styling (headers, rows, borders)
- [ ] Update ul/ol/li styling
- [ ] Update hr styling
- [ ] Test all markdown elements render correctly
- [ ] Strategy for CSS selectors:
- Option A: Use CSS custom properties in nested selectors
- Option B: Use dual selectors with theme attribute
- Option C: Create wrapper with theme-specific class
### Arcade Games - Complement Race
- [ ] Update `src/app/arcade/complement-race/components/GameDisplay.tsx`
- [ ] Update background gradient
- [ ] Update text colors
- [ ] Update interactive element colors
- [ ] Update feedback message colors
- [ ] Update `src/app/arcade/complement-race/components/PressureGauge.tsx`
- [ ] Update gauge background
- [ ] Update text colors
- [ ] Update SVG colors
- [ ] Update `src/app/arcade/complement-race/components/PassengerCard.tsx`
- [ ] Update card styling
- [ ] Update custom color usage
- [ ] Update `src/app/arcade/complement-race/components/RaceTrack/CircularTrack.tsx`
- [ ] SVG fill colors (#7cb342, #d97757)
- [ ] SVG stroke colors
- [ ] Consider CSS filter strategy for SVG
- [ ] Update `src/app/arcade/complement-race/components/RaceTrack/GhostTrain.tsx`
- [ ] SVG fill colors
- [ ] Drop shadow colors
- [ ] Update `src/app/arcade/complement-race/components/RaceTrack/LinearTrack.tsx`
- [ ] Track colors
- [ ] Marker colors
### Arcade Games - Rithmomachia
- [ ] Update `src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx`
- [ ] Player badge styling
- [ ] Board background
- [ ] UI element colors
- [ ] Update `src/app/arcade/rithmomachia/guide/page.tsx`
- [ ] Guide page background (#f3f4f6)
- [ ] Text colors
- [ ] Component styling
### SVG Graphics Strategy
- [ ] Decide on approach for blog SVGs:
- Option A: Generate light/dark variants (tedious but reliable)
- Option B: Use CSS filter inversion (lossy but quick)
- Option C: Use CSS variable injection (complex but elegant)
- Option D: Commit both variants to repo
- [ ] Apply chosen strategy to:
- [ ] `/public/blog/difficulty-examples/` (9 files)
- [ ] `/public/blog/ten-frame-examples/` (3 files)
### Testing Phase 3
- [ ] Blog markdown displays correctly in both themes
- [ ] All markdown element colors contrast properly
- [ ] Arcade games playable in both themes
- [ ] SVG graphics visible in both themes
- [ ] No visual artifacts from color changes
- [ ] Run quality checks: `npm run pre-commit`
## Phase 4: Testing & Polish (Week 3-4)
### Visual Testing
- [ ] Manual review of all pages
- [ ] Homepage
- [ ] Blog index
- [ ] Blog post (with all markdown elements)
- [ ] Guide page
- [ ] Games page
- [ ] All arcade games
- [ ] Navigation and dropdowns
- [ ] Screenshot comparison (if available)
- [ ] Compare light and dark variants
- [ ] Check for layout shifts
- [ ] Verify theme consistency
- [ ] Cross-browser testing
- [ ] Chrome/Edge (latest)
- [ ] Firefox (latest)
- [ ] Safari (latest)
- [ ] Mobile Chrome/Safari
### Accessibility Testing
- [ ] Color contrast audit
- [ ] Use WAVE WebAIM checker
- [ ] Use Axe DevTools
- [ ] Verify WCAG AA (4.5:1) for all text
- [ ] Verify WCAG AA (3:1) for large text
- [ ] Check color combinations for color-blind users
- [ ] Color-blindness simulation
- [ ] Use ColorOracle simulator
- [ ] Test protanopia (red-blind)
- [ ] Test deuteranopia (green-blind)
- [ ] Test tritanopia (blue-blind)
- [ ] Motion/animation
- [ ] Test with `prefers-reduced-motion`
- [ ] Verify animations still work appropriately
- [ ] Check for jarring transitions
### Functionality Testing
- [ ] Theme toggle functionality
- [ ] Toggle switches theme immediately
- [ ] Settings persist across page reloads
- [ ] Settings persist across tab closes
- [ ] System preference respected on first visit
- [ ] Game functionality
- [ ] All games playable in both themes
- [ ] No gameplay issues from color changes
- [ ] SVG rendering correct
- [ ] Interactive elements
- [ ] Buttons clickable and visible
- [ ] Form inputs usable
- [ ] Dropdowns functional
- [ ] Modals display correctly
### Performance Testing
- [ ] No layout shift on theme change
- [ ] Theme switch is instant (no flicker)
- [ ] No performance regression
- [ ] Bundle size unchanged significantly
### Documentation
- [ ] Update THEME_AUDIT.md with implementation notes
- [ ] Document color palette choices
- [ ] Document any deviations from plan
- [ ] Create theme customization guide for future work
### Final Quality Check
- [ ] Run `npm run pre-commit` (all checks pass)
- [ ] No console errors or warnings
- [ ] No TypeScript errors
- [ ] No linting errors
- [ ] All tests pass
## Deployment & Communication
### Pre-Launch
- [ ] Get stakeholder review and approval
- [ ] Test on staging environment
- [ ] Get final accessibility sign-off
- [ ] Prepare release notes
### Launch
- [ ] Create git commit with all changes
- [ ] Push to main branch
- [ ] Monitor GitHub Actions build
- [ ] Verify deployment to production
- [ ] Manual smoke test on production
### Post-Launch
- [ ] Monitor error logs for issues
- [ ] Gather user feedback
- [ ] Document any issues found
- [ ] Plan follow-up improvements
## Risk Mitigation
### Known Challenges
- [ ] Blog markdown styling complexity - plan extra time
- [ ] SVG color handling - test multiple browsers
- [ ] SSR hydration - test server vs client rendering
- [ ] Third-party component theming - verify compatibility
### Contingency Plans
- [ ] If SVG strategy fails: use PNG variants as fallback
- [ ] If markdown styling breaks: revert to inline styles temporarily
- [ ] If performance issues: consider lazy-loading theme CSS
- [ ] If accessibility fails: adjust color palette before launch
### Rollback Plan
- [ ] Keep previous version in git history
- [ ] Test rollback procedure before launch
- [ ] Have quick revert command ready
- [ ] Monitor metrics for issues
## Success Criteria
- [x] Site fully functional in light mode
- [x] Site fully functional in dark mode
- [x] System preference detection working
- [x] Theme persistence working
- [x] All WCAG AA accessibility requirements met
- [x] No performance regression
- [x] All tests passing
- [x] User feedback positive
- [x] No critical bugs reported
## Timeline
| Phase | Duration | Status |
| ------------------------- | ------------- | --------------- |
| Phase 1: Foundation | 1 week | Not started |
| Phase 2: Core Pages | 1-2 weeks | Not started |
| Phase 3: Complex Content | 1-2 weeks | Not started |
| Phase 4: Testing & Polish | 1 week | Not started |
| **Total** | **2-3 weeks** | **Not started** |
---
**Document Version:** 1.0
**Last Updated:** 2025-11-07
**Status:** Ready for implementation

View File

@@ -1,903 +0,0 @@
# Light/Dark Theme Implementation Plan
## Status: Phase 1 Complete ✅
**Last Updated:** 2025-01-07
## Overview
This document outlines the complete plan for implementing auto-switching light/dark themes across the entire Abaci.One website. The implementation follows a page-by-page approach to allow incremental testing and rollout.
---
## Phase 1: Foundation ✅ COMPLETE
**Goal:** Set up the infrastructure needed for theming across the entire app.
### Completed Tasks
- [x] Add semantic color tokens to Panda CSS config
- `bg.canvas`, `bg.surface`, `bg.subtle`, `bg.muted`
- `text.primary`, `text.secondary`, `text.muted`, `text.inverse`
- `border.default`, `border.muted`, `border.emphasis`
- `accent.default`, `accent.emphasis`, `accent.muted`, `accent.subtle`
- `interactive.hover`, `interactive.active`
- [x] Create ThemeProvider with system preference detection
- Detects `prefers-color-scheme` media query
- Supports `light`, `dark`, `system` modes
- Persists to localStorage
- Applies `data-theme` attribute to document root
- [x] Update global styles for theme support
- Added `color-scheme` CSS property
- Configured `data-theme` selectors
- [x] Create theme toggle component
- Simple button with ☀️/🌙 icons
- Added to navigation bar
- Uses semantic tokens as proof-of-concept
- [x] Regenerate Panda CSS with new tokens
- `pnpm panda codegen` executed successfully
### Files Modified
- `panda.config.ts` - Added semantic tokens and conditions
- `src/contexts/ThemeContext.tsx` - NEW
- `src/components/ThemeToggle.tsx` - NEW
- `src/components/ClientProviders.tsx` - Added ThemeProvider
- `src/components/AppNavBar.tsx` - Added ThemeToggle
- `src/app/globals.css` - Added color-scheme property
### Testing Notes
**Current State:** Only the theme toggle button uses semantic tokens. Rest of site still hardcoded to dark colors.
**What to Test:**
- Theme toggle button appears in nav bar (top-right)
- Clicking toggles between light/dark
- Theme persists on page reload
- Button styling changes with theme (bg, border, text colors)
- System preference is detected on first visit
---
## Phase 2: Core Pages (Week 1-2)
**Goal:** Convert high-traffic, simple pages to use semantic tokens.
### 2.1: Homepage (`src/app/page.tsx`)
**Complexity:** MEDIUM - Gradients and hero section need careful adjustment
**Current Issues:**
- Background: `rgba(15, 23, 42, 1)` hardcoded
- Hero gradient: Dark purple/blue hardcoded
- Skill cards: Dark backgrounds with light borders
- Game cards: `rgba(30, 41, 59, 1)` backgrounds
**Changes Needed:**
```typescript
// Hero section background
bg: 'bg.canvas' // instead of rgba(15, 23, 42, 1)
// Hero gradient overlay
background: 'linear-gradient(135deg,
token(colors.accent.subtle) 0%,
token(colors.bg.canvas) 100%)'
// Skill cards
bg: 'bg.surface'
borderColor: 'border.default'
color: 'text.primary'
// Headings
color: 'text.primary' // instead of white/#f1f5f9
```
**Lines to Change:**
- Line 45-50: Main container background
- Line 88-95: Hero gradient
- Line 182-190: Skill cards
- Line 282-290: Game cards
- Line 156: Main heading color
**Testing Checklist:**
- [ ] Hero gradient looks good in both modes
- [ ] Skill cards have proper contrast
- [ ] Game preview cards are readable
- [ ] Hover states work in both themes
- [ ] Text hierarchy is maintained
---
### 2.2: Blog Index (`src/app/blog/page.tsx`)
**Complexity:** LOW - Straightforward color replacements
**Current Issues:**
- Background: `gray.900` (`#111827`)
- Card backgrounds: `rgba(30, 41, 59, 0.6)`
- Text colors: Hardcoded light grays
- Borders: `rgba(75, 85, 99, 0.5)`
**Changes Needed:**
```typescript
// Main container
bg: "bg.canvas";
// Blog cards
bg: "bg.surface";
borderColor: "border.default";
// Title
color: "text.primary";
// Description
color: "text.secondary";
// Meta text (date, author)
color: "text.muted";
// Tags
bg: "accent.muted";
color: "accent.emphasis";
```
**Lines to Change:**
- Line 31: Container background
- Line 55-65: Card styling
- Line 82: Title color
- Line 94: Description color
- Line 107-115: Tag styling
**Testing Checklist:**
- [ ] Card backgrounds visible in both modes
- [ ] Text readable with proper contrast
- [ ] Hover states clear
- [ ] Tags stand out appropriately
---
### 2.3: Blog Post Page (`src/app/blog/[slug]/page.tsx`)
**Complexity:** HIGH - Complex markdown styling with 10+ nested selectors
**Current Issues:**
- Background pattern: Assumes dark background
- Markdown content: 100+ lines of nested CSS (h1, h2, p, code, tables, blockquotes, etc.)
- Code blocks: Dark background required
- Inline SVGs: Use CSS custom properties (already done ✅)
**Changes Needed:**
```typescript
// Main container
bg: 'bg.canvas'
// Article content wrapper
color: 'text.primary'
// Headings
'& h1': { color: 'text.primary' }
'& h2': { color: 'accent.emphasis' }
'& h3': { color: 'accent.default' }
// Paragraphs
'& p': { color: 'text.primary' }
// Links
'& a': {
color: 'accent.default',
_hover: { color: 'accent.emphasis' }
}
// Code blocks
'& pre': {
bg: 'bg.muted',
borderColor: 'border.emphasis',
color: 'text.primary'
}
// Inline code
'& code': {
bg: 'bg.subtle',
color: 'accent.emphasis',
borderColor: 'border.default'
}
// Blockquotes
'& blockquote': {
borderColor: 'accent.default',
bg: 'accent.subtle',
color: 'text.secondary'
}
// Tables
'& th': {
bg: 'accent.muted',
color: 'accent.emphasis',
borderColor: 'accent.default'
}
'& td': {
borderColor: 'border.default',
color: 'text.secondary'
}
'& tr:hover td': {
bg: 'interactive.hover'
}
```
**Lines to Change:**
- Line 78-82: Main container background
- Line 86-96: Background pattern (may need two versions)
- Line 217-357: ALL markdown content styles
**Special Considerations:**
- Background pattern might need `opacity` adjustment for light mode
- Code syntax highlighting might need separate light/dark themes
- SVG custom properties already work (done in earlier work) ✅
**Testing Checklist:**
- [ ] All heading levels readable
- [ ] Links have proper contrast
- [ ] Code blocks readable in both modes
- [ ] Tables properly styled
- [ ] Blockquotes stand out but aren't jarring
- [ ] Inline SVGs (ten-frames) still work
- [ ] Background pattern doesn't interfere
---
### 2.4: Guide Page (`src/app/guide/page.tsx`)
**Complexity:** LOW - Already has light styling, needs refactoring for consistency
**Current Issues:**
- Uses isolated light mode styling
- Doesn't use semantic tokens
- Needs integration with theme system
**Changes Needed:**
```typescript
// Convert existing light styles to semantic tokens
bg: "bg.canvas"; // instead of white
color: "text.primary"; // instead of gray.900
// Section backgrounds
bg: "bg.surface";
// Borders
borderColor: "border.default";
```
**Lines to Change:**
- Line 28: Main background
- Line 45-50: Section cards
- Line 72: Text colors
**Testing Checklist:**
- [ ] Maintains current light mode appearance
- [ ] Works in dark mode too
- [ ] Consistent with other pages
---
### 2.5: Games Listing (`src/app/games/page.tsx`)
**Complexity:** LOW - Similar to blog index
**Current Issues:**
- Background: Dark hardcoded
- Game cards: Dark backgrounds
- Text: Light grays hardcoded
**Changes Needed:**
```typescript
bg: "bg.canvas";
// Game cards
bg: "bg.surface";
borderColor: "border.default";
// Card hover
bg: "interactive.hover";
```
**Lines to Change:**
- Line 35: Main background
- Line 58-65: Game card styling
- Line 88: Title colors
**Testing Checklist:**
- [ ] Cards visible in both modes
- [ ] Hover states work
- [ ] Game thumbnails look good
---
## Phase 3: Complex Content (Week 2-3)
**Goal:** Handle arcade games, SVGs, and complex interactive components.
### 3.1: Arcade Games
**Affected Files (5+ games):**
- `src/arcade-games/complement-race/`
- `src/arcade-games/card-sorting/`
- `src/arcade-games/memory-quiz/`
- `src/arcade-games/matching-pairs/`
- `src/arcade-games/rithmomachia/`
**Common Issues:**
- Inline SVGs with hardcoded colors (#7cb342, #d97757, #6366f1, etc.)
- Game board backgrounds assume dark theme
- Score displays, timers hardcoded
- Each game has 10+ color references
**Strategy:**
1. **Create game-specific theme tokens** (extend Panda config):
```typescript
semanticTokens: {
colors: {
'game.background': {
value: { base: '#f8fafc', _dark: '#1e293b' }
},
'game.surface': {
value: { base: '#ffffff', _dark: '#334155' }
},
'game.success': {
value: { base: '#22c55e', _dark: '#4ade80' }
},
'game.error': {
value: { base: '#ef4444', _dark: '#f87171' }
},
'game.warning': {
value: { base: '#f59e0b', _dark: '#fbbf24' }
}
}
}
```
2. **Handle SVGs:**
- Option A: Convert to inline React components using `currentColor`
- Option B: Create dual SVG versions (light/dark)
- Option C: Use CSS filters to invert colors
3. **Game-by-Game Conversion:**
- Start with simplest (Memory Quiz, Card Sorting)
- Then Complement Race (most complex)
- Finally Rithmomachia (largest, but well-structured)
**Per-Game Checklist Template:**
- [ ] Convert background colors
- [ ] Update text colors
- [ ] Fix SVG colors
- [ ] Test game board visibility
- [ ] Verify score/timer readability
- [ ] Check button states
- [ ] Test animations don't break
---
### 3.2: Navigation Components
**Affected Files:**
- `src/components/AppNavBar.tsx` (partially done ✅)
- `src/components/nav/*.tsx` (dropdowns, modals)
**Current Issues:**
- Dropdowns use hardcoded dark backgrounds
- Room creation modal dark themed
- Player indicators hardcoded colors
**Changes Needed:**
```typescript
// Dropdown menus
bg: "bg.surface";
borderColor: "border.default";
// Modal overlays
bg: "rgba(0, 0, 0, 0.5)"; // Keep semi-transparent overlay
// Modal content
bg: "bg.canvas";
// Player status indicators
// Need semantic tokens: online/offline/away colors
```
**Files to Update:**
- `CreateRoomModal.tsx`
- `JoinRoomModal.tsx`
- `RoomInfo.tsx`
- `NetworkPlayerIndicator.tsx`
- `AbacusDisplayDropdown.tsx`
- `LanguageSelector.tsx`
---
### 3.3: External SVG Strategy
**Problem:** Blog ten-frame examples use external SVG files loaded via `<img>` tags. CSS variables don't pass through.
**Current Solution:** Hardcoded light colors (white text, etc.) for dark background.
**Future Options:**
**Option 1: Dual SVG Versions** (Recommended)
```typescript
// Generate both versions
generateTenFrameExamples.ts produces:
- with-ten-frames-light.svg
- with-ten-frames-dark.svg
// In markdown, use theme-aware img src
<img src={`/blog/ten-frame-examples/${filename}-${resolvedTheme}.svg`} />
```
**Option 2: Inline SVGs in Markdown**
- Embed SVG code directly (larger file size)
- CSS variables work
- More maintainable
**Option 3: Dynamic SVG Loading**
- Use React component to load and theme SVGs
- Requires converting blog posts to MDX
**Decision:** Start with Option 1 (dual versions) for blog examples.
---
## Phase 4: Polish & Testing (Week 3)
**Goal:** Ensure quality, accessibility, and performance.
### 4.1: Accessibility Audit
**WCAG AA Requirements:**
- Contrast ratio ≥ 4.5:1 for normal text
- Contrast ratio ≥ 3:1 for large text (18pt+)
- Contrast ratio ≥ 3:1 for UI components
**Testing Tools:**
- Chrome DevTools Lighthouse
- axe DevTools
- WebAIM Contrast Checker
**Critical Areas:**
- Purple accent on light backgrounds (may need darker shade)
- Gold/amber accents (check contrast)
- Game board elements (must be distinguishable)
**Token Adjustments Needed:**
```typescript
// If contrast fails, adjust:
'accent.default': {
value: {
base: '#6d28d9', // Darker purple for light mode
_dark: '#a78bfa',
}
}
```
---
### 4.2: Cross-Browser Testing
**Browsers to Test:**
- Chrome/Edge (Chromium)
- Firefox
- Safari (macOS + iOS)
- Mobile browsers
**Known Issues:**
- Safari has different `prefers-color-scheme` behavior
- Firefox may handle `color-scheme` differently
- Mobile browsers: test on actual devices
**Test Matrix:**
| Browser | Light Mode | Dark Mode | System Auto |
|---------|-----------|-----------|-------------|
| Chrome | ⬜ | ⬜ | ⬜ |
| Firefox | ⬜ | ⬜ | ⬜ |
| Safari | ⬜ | ⬜ | ⬜ |
| iOS Safari | ⬜ | ⬜ | ⬜ |
| Android Chrome | ⬜ | ⬜ | ⬜ |
---
### 4.3: Performance Optimization
**Considerations:**
1. **Initial Render Flash Prevention:**
```typescript
// Add script to <head> to set theme before render
<script dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = localStorage.getItem('theme') || 'system';
const resolvedTheme = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.setAttribute('data-theme', resolvedTheme);
document.documentElement.classList.add(resolvedTheme);
})();
`
}} />
```
2. **CSS Variable Performance:**
- Semantic tokens compile to CSS vars
- Modern browsers handle this efficiently
- No measurable perf impact expected
3. **SVG Loading:**
- Dual SVG versions add minimal overhead
- Consider lazy loading for below-fold content
---
### 4.4: Enhanced Theme Toggle (Optional)
**Current:** Simple button with emoji
**Enhanced Version:**
- Three-state toggle (Light / System / Dark)
- Animated transition
- Keyboard accessible
- Show current system preference
**Design:**
```
┌─────────────────────────┐
│ ☀️ Light System 🌙 Dark │
│ ●─────────────○ │
└─────────────────────────┘
```
**Implementation:**
```typescript
<SegmentedControl>
<Option value="light"> Light</Option>
<Option value="system">🖥 System</Option>
<Option value="dark">🌙 Dark</Option>
</SegmentedControl>
```
---
## Token Reference
### Complete Semantic Token List
```typescript
// Backgrounds
bg.canvas; // Main page background
bg.surface; // Card/panel backgrounds
bg.subtle; // Subtle backgrounds (hover states)
bg.muted; // Muted backgrounds (disabled states)
// Text
text.primary; // Main text color
text.secondary; // Secondary/helper text
text.muted; // Muted text (metadata, captions)
text.inverse; // Text on colored backgrounds
// Borders
border.default; // Standard borders
border.muted; // Subtle borders
border.emphasis; // Emphasized borders
// Accents (Purple theme)
accent.default; // Primary accent color
accent.emphasis; // Stronger accent (hover, active)
accent.muted; // Very subtle accent (backgrounds)
accent.subtle; // Subtle accent (highlights)
// Interactive
interactive.hover; // Hover state backgrounds
interactive.active; // Active/pressed state backgrounds
```
### Usage Examples
```typescript
// Card component
<div className={css({
bg: 'bg.surface',
borderWidth: '1px',
borderColor: 'border.default',
borderRadius: '0.5rem',
p: '1rem',
_hover: {
bg: 'interactive.hover',
borderColor: 'border.emphasis',
}
})}>
<h3 className={css({ color: 'text.primary' })}>
Title
</h3>
<p className={css({ color: 'text.secondary' })}>
Description text
</p>
</div>
```
---
## Migration Checklist
Use this checklist when converting each page:
### Before Starting
- [ ] Read page code, note all hardcoded colors
- [ ] Identify special cases (gradients, SVGs, animations)
- [ ] Check for any inline styles or !important overrides
### During Conversion
- [ ] Replace background colors with `bg.*` tokens
- [ ] Replace text colors with `text.*` tokens
- [ ] Replace border colors with `border.*` tokens
- [ ] Update hover states to use `interactive.*` tokens
- [ ] Convert accent colors to `accent.*` tokens
- [ ] Test in light mode
- [ ] Test in dark mode
- [ ] Test system auto-switch
### After Conversion
- [ ] Run `npm run pre-commit` (type-check, format, lint)
- [ ] Visual regression test (compare before/after screenshots)
- [ ] Verify no console errors
- [ ] Check accessibility contrast ratios
- [ ] Test on mobile viewport
- [ ] Commit with descriptive message
---
## Known Issues & Workarounds
### Issue 1: Panda CSS Token Syntax
**Problem:** Tokens in conditions need specific syntax.
**Wrong:**
```typescript
color: theme === "dark" ? "text.primary" : "gray.900";
```
**Right:**
```typescript
color: "text.primary"; // Token handles both modes
```
---
### Issue 2: Gradients with Tokens
**Problem:** CSS gradients can't directly use semantic tokens in string templates.
**Workaround:**
```typescript
// Use token() function
background: `linear-gradient(135deg,
token(colors.accent.subtle),
token(colors.bg.canvas))`
// Or use CSS variables
background: 'linear-gradient(135deg,
var(--colors-accent-subtle),
var(--colors-bg-canvas))'
```
---
### Issue 3: Third-Party Components
**Problem:** Some components (Radix UI, etc.) have their own theming.
**Strategy:**
- Use CSS variables to style Radix components
- Override with Panda tokens where possible
- Some components may need separate light/dark styling
---
## Testing Instructions
### For Each Converted Page:
1. **Visual Test:**
- Open page in light mode
- Take screenshot
- Switch to dark mode
- Take screenshot
- Compare: all content should be readable
2. **Interaction Test:**
- Test all buttons, links, forms
- Verify hover states
- Check focus indicators
- Test animations
3. **Responsive Test:**
- Test on mobile (375px width)
- Test on tablet (768px width)
- Test on desktop (1440px width)
4. **Browser Test:**
- Test in Chrome
- Test in Firefox
- Test in Safari (if available)
5. **Accessibility Test:**
- Run Lighthouse audit
- Check contrast ratios
- Test keyboard navigation
---
## Progress Tracking
### Pages Converted to Semantic Tokens
- [ ] Homepage (`src/app/page.tsx`)
- [ ] Blog Index (`src/app/blog/page.tsx`)
- [ ] Blog Post (`src/app/blog/[slug]/page.tsx`)
- [ ] Guide Page (`src/app/guide/page.tsx`)
- [ ] Games Listing (`src/app/games/page.tsx`)
- [ ] Join Page (`src/app/join/[code]/page.tsx`)
### Components Converted
- [x] ThemeToggle ✅
- [x] AppNavBar (partial - toggle only) ✅
- [ ] CreateRoomModal
- [ ] JoinRoomModal
- [ ] RoomInfo
- [ ] AbacusDisplayDropdown
- [ ] LanguageSelector
- [ ] NetworkPlayerIndicator
- [ ] Tutorial components
### Arcade Games Converted
- [ ] Complement Race
- [ ] Card Sorting
- [ ] Memory Quiz
- [ ] Matching Pairs
- [ ] Rithmomachia
---
## Rollback Plan
If major issues arise:
1. **Quick Rollback:**
```bash
git revert <commit-hash>
```
2. **Feature Flag (Future):**
```typescript
// Add environment variable
NEXT_PUBLIC_ENABLE_THEME_SWITCHING = true;
// In ThemeProvider
if (!process.env.NEXT_PUBLIC_ENABLE_THEME_SWITCHING) {
return children; // Skip theming
}
```
3. **Gradual Rollout:**
- Deploy with theme system disabled by default
- Enable per-page using URL param: `?theme=light`
- Monitor for issues
- Enable globally when stable
---
## Future Enhancements
### Post-Launch Improvements
1. **Theme Customization:**
- Allow users to customize accent colors
- Save preferred color schemes per-game
- Export/import theme preferences
2. **Automatic Theme Scheduling:**
- Auto-switch based on time of day
- Sunrise/sunset detection
3. **High Contrast Mode:**
- Extra high contrast tokens for accessibility
- Separate from light/dark modes
4. **Theme Preview:**
- Live preview before applying
- A/B test different color schemes
---
## Resources
### Documentation
- [Panda CSS Themes](https://panda-css.com/docs/theming/tokens)
- [Next.js Dark Mode](https://nextjs.org/docs/pages/building-your-application/optimizing/fonts#with-tailwind-css)
- [WCAG Contrast Guidelines](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html)
### Tools
- [Coolors Contrast Checker](https://coolors.co/contrast-checker)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [Chrome DevTools Color Picker](https://developer.chrome.com/docs/devtools/accessibility/contrast/)
---
## Notes
- Implementation started: 2025-01-07
- Target completion: 3 weeks from start
- Priority: User-facing pages first, admin/debug pages last
- Breaking changes: None expected (additive only)

View File

@@ -7,9 +7,7 @@ The tutorial system is a sophisticated interactive learning platform for teachin
## Key Components
### 1. TutorialPlayer (`/src/components/tutorial/TutorialPlayer.tsx`)
The main tutorial playback component that:
- Displays tutorial steps progressively
- Highlights specific beads users should interact with
- Provides real-time feedback and tooltips
@@ -18,7 +16,6 @@ The main tutorial playback component that:
- Auto-advances to next step on correct completion
**Key Features:**
- **Bead Highlighting**: Visual indicators showing which beads to manipulate
- **Step Progress**: Shows current step out of total steps
- **Error Feedback**: Provides hints when user makes mistakes
@@ -26,9 +23,7 @@ The main tutorial playback component that:
- **Pedagogical Decomposition**: Explains the "why" behind each operation
### 2. TutorialEditor (`/src/components/tutorial/TutorialEditor.tsx`)
A full-featured editor for creating and editing tutorials:
- Visual step editor
- Bead highlight configuration
- Multi-step instruction editor
@@ -42,69 +37,67 @@ A full-featured editor for creating and editing tutorials:
```typescript
interface Tutorial {
id: string;
title: string;
description: string;
category: string;
difficulty: "beginner" | "intermediate" | "advanced";
estimatedDuration: number; // minutes
steps: TutorialStep[];
tags: string[];
author: string;
version: string;
createdAt: Date;
updatedAt: Date;
isPublished: boolean;
id: string
title: string
description: string
category: string
difficulty: 'beginner' | 'intermediate' | 'advanced'
estimatedDuration: number // minutes
steps: TutorialStep[]
tags: string[]
author: string
version: string
createdAt: Date
updatedAt: Date
isPublished: boolean
}
interface TutorialStep {
id: string;
title: string;
problem: string; // e.g. "2 + 3"
description: string; // User-facing explanation
startValue: number; // Initial abacus value
targetValue: number; // Goal value
expectedAction: "add" | "remove" | "multi-step";
actionDescription: string;
id: string
title: string
problem: string // e.g. "2 + 3"
description: string // User-facing explanation
startValue: number // Initial abacus value
targetValue: number // Goal value
expectedAction: 'add' | 'remove' | 'multi-step'
actionDescription: string
// Bead highlighting
highlightBeads?: Array<{
placeValue: number; // 0=ones, 1=tens, etc.
beadType: "heaven" | "earth";
position?: number; // For earth beads: 0-3
}>;
placeValue: number // 0=ones, 1=tens, etc.
beadType: 'heaven' | 'earth'
position?: number // For earth beads: 0-3
}>
// Progressive step highlighting
stepBeadHighlights?: Array<{
placeValue: number;
beadType: "heaven" | "earth";
position?: number;
stepIndex: number; // Which instruction step
direction: "up" | "down" | "activate" | "deactivate";
order?: number; // Order within step
}>;
placeValue: number
beadType: 'heaven' | 'earth'
position?: number
stepIndex: number // Which instruction step
direction: 'up' | 'down' | 'activate' | 'deactivate'
order?: number // Order within step
}>
totalSteps?: number; // For multi-step operations
multiStepInstructions?: string[]; // Sequential instructions
totalSteps?: number // For multi-step operations
multiStepInstructions?: string[] // Sequential instructions
// Tooltips and guidance
tooltip: {
content: string; // Short title
explanation: string; // Detailed explanation
};
content: string // Short title
explanation: string // Detailed explanation
}
}
```
### 4. Tutorial Converter (`/src/utils/tutorialConverter.ts`)
Utility that converts the original `GuidedAdditionTutorial` data into the new tutorial format:
- `guidedAdditionSteps`: Array of tutorial steps from basic addition to complements
- `convertGuidedAdditionTutorial()`: Converts to Tutorial object
- `getTutorialForEditor()`: Main export used in the app
**Current Tutorial Steps:**
1. Basic Addition (0+1, 1+1, 2+1, 3+1)
2. Heaven Bead Introduction (0+5, 5+1)
3. Five Complements (3+4, 2+3 using 5-complement method)
@@ -113,13 +106,11 @@ Utility that converts the original `GuidedAdditionTutorial` data into the new tu
### 5. Supporting Utilities
**`/src/utils/abacusInstructionGenerator.ts`**
- Automatically generates step-by-step instructions from start/target values
- Creates bead highlight data
- Determines movement directions
**`/src/utils/beadDiff.ts`**
- Calculates differences between abacus states
- Generates visual feedback tooltips
- Explains what changed and why
@@ -167,53 +158,50 @@ return <TutorialPlayer tutorial={friendsOf5Tutorial} />
```typescript
const customTutorial: Tutorial = {
id: "my-tutorial",
title: "My Custom Tutorial",
description: "Learning something new",
category: "Custom",
difficulty: "beginner",
id: 'my-tutorial',
title: 'My Custom Tutorial',
description: 'Learning something new',
category: 'Custom',
difficulty: 'beginner',
estimatedDuration: 5,
steps: [
{
id: "step-1",
title: "Add 2",
problem: "0 + 2",
description: "Move two earth beads up",
id: 'step-1',
title: 'Add 2',
problem: '0 + 2',
description: 'Move two earth beads up',
startValue: 0,
targetValue: 2,
expectedAction: "add",
actionDescription: "Add two earth beads",
expectedAction: 'add',
actionDescription: 'Add two earth beads',
highlightBeads: [
{ placeValue: 0, beadType: "earth", position: 0 },
{ placeValue: 0, beadType: "earth", position: 1 },
{ placeValue: 0, beadType: 'earth', position: 0 },
{ placeValue: 0, beadType: 'earth', position: 1 }
],
tooltip: {
content: "Adding 2",
explanation: "Push two earth beads up to represent 2",
},
},
content: 'Adding 2',
explanation: 'Push two earth beads up to represent 2'
}
}
],
tags: ["custom"],
author: "Me",
version: "1.0.0",
tags: ['custom'],
author: 'Me',
version: '1.0.0',
createdAt: new Date(),
updatedAt: new Date(),
isPublished: true,
};
isPublished: true
}
```
## Current Implementation Locations
**Live Tutorials:**
- `/guide` - Second tab "Arithmetic Operations" contains the full guided addition tutorial
**Editor:**
- `/tutorial-editor` - Full tutorial editing interface
**Storybook:**
- Multiple tutorial stories in `/src/components/tutorial/*.stories.tsx`
## Key Design Principles

View File

@@ -3,13 +3,11 @@
## Confirmations and Dialogs
**NEVER use native browser dialogs:**
-`alert()`
-`confirm()`
-`prompt()`
**ALWAYS use inline React-based confirmations:**
- Show confirmation UI in-place using React state
- Provide Cancel and Confirm buttons
- Use descriptive warning messages with appropriate emoji (⚠️)
@@ -48,7 +46,6 @@ const [confirming, setConfirming] = useState(false)
### Real Examples
See `/src/components/nav/ModerationPanel.tsx` for production examples:
- Transfer ownership confirmation (lines 1793-1929)
- Unban user confirmation (shows inline warning with Cancel/Confirm)
@@ -63,7 +60,6 @@ See `/src/components/nav/ModerationPanel.tsx` for production examples:
### Migration Checklist
When replacing native dialogs:
- [ ] Add state variable for confirmation (e.g., `const [confirming, setConfirming] = useState(false)`)
- [ ] Remove the `confirm()` or `alert()` call from the handler
- [ ] Replace the original UI with conditional rendering
@@ -87,7 +83,6 @@ See `.claude/CLAUDE.md` for complete Panda CSS documentation.
## Emoji Usage
Emojis are used liberally throughout the UI for visual communication:
- 👑 Host/owner status
- ⏳ Waiting states
- ⚠️ Warnings and confirmations

View File

@@ -1,442 +0,0 @@
# Worksheet Config Persistence Architecture
## Overview
This document explains how worksheet configurations are persisted, shared, and restored across the application.
**Key Principle:** We separate **PRIMARY STATE** (what we save) from **DERIVED STATE** (what we calculate).
## Field Categories
### PRIMARY STATE (Persisted)
These fields define the worksheet configuration and MUST be saved:
```typescript
{
// Structure
problemsPerPage: number // How many problems per page (e.g., 20)
cols: number // Grid columns (e.g., 4)
pages: number // How many pages (e.g., 5)
orientation: 'portrait' | 'landscape'
// Problem Space
digitRange: { min: number, max: number } // 1-5 digits
operator: 'addition' | 'subtraction' | 'mixed'
// Regrouping Distribution
pAnyStart: number // Probability of any-column regrouping
pAllStart: number // Probability of all-column regrouping
interpolate: boolean // Gradual difficulty progression
// Display Mode (discriminated union)
mode: 'smart' | 'manual' | 'mastery'
// Smart Mode Fields
displayRules?: { // Conditional per-problem scaffolding
tenFrames: 'never' | 'sometimes' | 'always'
carryBoxes: 'never' | 'sometimes' | 'always'
placeValueColors: 'never' | 'sometimes' | 'always'
answerBoxes: 'never' | 'sometimes' | 'always'
problemNumbers: 'never' | 'sometimes' | 'always'
cellBorders: 'never' | 'sometimes' | 'always'
borrowNotation: 'never' | 'sometimes' | 'always'
borrowingHints: 'never' | 'sometimes' | 'always'
}
difficultyProfile?: string // Smart mode preset (e.g., 'earlyLearner')
// Manual Mode Fields
showCarryBoxes?: boolean
showAnswerBoxes?: boolean
showPlaceValueColors?: boolean
showProblemNumbers?: boolean
showCellBorder?: boolean
showTenFrames?: boolean
showTenFramesForAll?: boolean
showBorrowNotation?: boolean
showBorrowingHints?: boolean
manualPreset?: string // Manual mode preset
// Mastery Mode Fields
currentStepId?: string
currentAdditionSkillId?: string
currentSubtractionSkillId?: string
// Personalization
name: string // Student name
fontSize: number // Font size in points
// Reproducibility (CRITICAL for sharing!)
seed: number // Random seed
prngAlgorithm: string // PRNG algorithm (e.g., 'mulberry32')
}
```
### DERIVED STATE (Calculated)
These fields are calculated from primary state and should NOT be saved:
```typescript
{
total: number; // = problemsPerPage × pages
rows: number; // = Math.ceil(problemsPerPage / cols)
}
```
**Why exclude these?**
- They're redundant (can be recalculated)
- Including them creates risk of inconsistency (e.g., `total: 20` but `pages: 100`)
- Primary state is the source of truth
### EPHEMERAL STATE (Not Persisted)
These fields are generated fresh at runtime and should NOT be saved:
```typescript
{
date: string; // Current date (e.g., "January 15, 2025")
}
```
**Why exclude?**
- Date should reflect when the worksheet is actually generated/printed
- User may generate worksheet days/weeks after creating the config
## Architecture: Blacklist Approach
### File: `src/app/create/worksheets/utils/extractConfigFields.ts`
```typescript
export function extractConfigFields(formState: WorksheetFormState) {
// Blacklist approach: Exclude only derived/ephemeral fields
const { rows, total, date, ...persistedFields } = formState;
return {
...persistedFields,
prngAlgorithm: persistedFields.prngAlgorithm ?? "mulberry32",
};
}
```
### Why Blacklist Instead of Whitelist?
**Old Approach (FRAGILE):**
```typescript
// Manually list every field - easy to forget new fields!
return {
problemsPerPage: formState.problemsPerPage,
cols: formState.cols,
pages: formState.pages,
// ... 30+ fields ...
// Oops, forgot to add the new field! Shared worksheets break!
};
```
**New Approach (ROBUST):**
```typescript
// Automatically include everything except derived fields
const { rows, total, date, ...persistedFields } = formState;
return persistedFields;
```
**Benefits:**
- ✅ New config fields automatically work in shared worksheets
- ✅ Only need to update if adding new DERIVED fields (rare)
- ✅ Much harder to accidentally break sharing
- ✅ Less maintenance burden
## Persistence Locations
### 1. localStorage (Auto-Save)
**Hook:** `src/hooks/useWorksheetAutoSave.ts`
```typescript
const config = extractConfigFields(formState);
localStorage.setItem("worksheet-addition-config", JSON.stringify(config));
```
**Purpose:** Restore user's work when they return to the page
**Restoration:**
```typescript
const saved = localStorage.getItem("worksheet-addition-config");
const config = saved ? JSON.parse(saved) : defaultConfig;
```
### 2. Database (Share Links)
**API Route:** `POST /api/worksheets/share`
```typescript
const config = extractConfigFields(formState);
await db.insert(worksheetShares).values({
id: shareId,
worksheetType: "addition",
config: JSON.stringify(config),
});
```
**Purpose:** Allow users to share exact worksheet configurations via URL
**Restoration:**
```typescript
const share = await db.query.worksheetShares.findFirst({
where: eq(worksheetShares.id, shareId),
});
const config = JSON.parse(share.config);
```
### 3. API Settings (User Preferences)
**API Route:** `POST /api/worksheets/settings`
```typescript
const config = extractConfigFields(formState);
await db.insert(worksheetSettings).values({
userId: session.userId,
type: "addition",
config: JSON.stringify(config),
});
```
**Purpose:** Save user's preferred defaults (future feature)
## State Reconstruction Flow
### When Loading a Shared Worksheet
1. **Fetch share data:**
```typescript
const response = await fetch(`/api/worksheets/share/${shareId}`);
const { config } = await response.json();
```
2. **Pass to validation:**
```typescript
const validation = validateWorksheetConfig(config);
```
3. **Validation calculates derived state:**
```typescript
// In validation.ts
const problemsPerPage = formState.problemsPerPage ?? 20;
const pages = formState.pages ?? 1;
const total = problemsPerPage * pages; // DERIVED!
const rows = Math.ceil(total / cols); // DERIVED!
```
4. **Return validated config with derived state:**
```typescript
return {
...persistedFields,
total, // Calculated
rows, // Calculated
date: getDefaultDate(), // Fresh!
};
```
## Common Bugs and Solutions
### Bug: Shared worksheets show wrong page count
**Cause:** Using `formState.total` as source of truth instead of calculating from `problemsPerPage × pages`
**Fix:**
```typescript
// ❌ WRONG - uses fallback when total is missing
const total = formState.total ?? 20;
// ✅ CORRECT - calculate from primary state
const problemsPerPage = formState.problemsPerPage ?? 20;
const pages = formState.pages ?? 1;
const total = problemsPerPage * pages;
```
### Bug: New config field doesn't persist
**Cause (Old):** Forgot to add field to `extractConfigFields` whitelist
**Solution:** Use blacklist approach - new fields automatically work!
### Bug: Shared worksheet generates different problems
**Cause:** Missing `seed` or `prngAlgorithm` in persisted config
**Solution:** `extractConfigFields` always includes these fields:
```typescript
const config = {
...persistedFields,
prngAlgorithm: persistedFields.prngAlgorithm ?? "mulberry32",
};
```
## Adding New Config Fields
### Checklist
When adding a new config field:
1. **Determine field category:**
- PRIMARY STATE? → No special handling needed! Blacklist approach handles it automatically
- DERIVED STATE? → Add to blacklist in `extractConfigFields.ts`
- EPHEMERAL STATE? → Add to blacklist in `extractConfigFields.ts`
2. **Add to type definitions:**
```typescript
// In config-schemas.ts
export const additionConfigV4Schema = z.object({
// ... existing fields ...
myNewField: z.string().optional(), // Add new field
});
```
3. **Update validation defaults (if needed):**
```typescript
// In validation.ts
const myNewField = formState.myNewField ?? "defaultValue";
```
4. **Test the flow:**
- Create worksheet with new field
- Save to localStorage
- Share the worksheet
- Open share link
- Verify new field is preserved
### Example: Adding a New Primary Field
```typescript
// 1. Update schema (config-schemas.ts)
export const additionConfigV4Schema = z.object({
// ... existing fields ...
headerText: z.string().optional(), // New field!
});
// 2. Update validation defaults (validation.ts)
const sharedFields = {
// ... existing fields ...
headerText: formState.headerText ?? "Math Practice",
};
// 3. Done! extractConfigFields automatically includes it
```
### Example: Adding a New Derived Field
```typescript
// 1. Update schema (config-schemas.ts)
// (Derived fields don't go in the persisted schema)
// 2. Calculate in validation (validation.ts)
const averageProblemsPerRow = Math.ceil(problemsPerPage / rows);
// 3. Add to blacklist (extractConfigFields.ts)
const { rows, total, date, averageProblemsPerRow, ...persistedFields } =
formState;
```
## Testing
### Manual Test: Share Link Preservation
1. Create a worksheet with specific config:
- 100 pages
- 20 problems per page
- 3-4 digit problems
- Smart mode with specific display rules
2. Click "Share" to create share link
3. Open share link in new incognito window
4. Verify ALL config matches:
- ✅ Total shows 2000 problems (100 × 20)
- ✅ Page count shows 100
- ✅ Digit range shows 3-4
- ✅ Display rules match original
- ✅ Problems are identical (same seed)
### Automated Test (TODO)
```typescript
describe("extractConfigFields", () => {
it("excludes derived state", () => {
const formState = {
problemsPerPage: 20,
pages: 5,
total: 100, // Should be excluded
rows: 5, // Should be excluded
};
const config = extractConfigFields(formState);
expect(config.problemsPerPage).toBe(20);
expect(config.pages).toBe(5);
expect(config.total).toBeUndefined();
expect(config.rows).toBeUndefined();
});
it("includes seed and prngAlgorithm", () => {
const formState = {
seed: 12345,
prngAlgorithm: "mulberry32",
};
const config = extractConfigFields(formState);
expect(config.seed).toBe(12345);
expect(config.prngAlgorithm).toBe("mulberry32");
});
});
```
## Related Files
- **`src/app/create/worksheets/utils/extractConfigFields.ts`** - Config extraction logic
- **`src/app/create/worksheets/validation.ts`** - Config validation and derived state calculation
- **`src/app/create/worksheets/types.ts`** - Type definitions (PRIMARY vs DERIVED)
- **`src/app/create/worksheets/config-schemas.ts`** - Zod schemas for validation
- **`src/hooks/useWorksheetAutoSave.ts`** - Auto-save to localStorage
- **`src/app/api/worksheets/share/route.ts`** - Share link creation API
- **`src/app/worksheets/shared/[id]/page.tsx`** - Shared worksheet viewer
## History
### 2025-01: Blacklist Refactor
**Problem:** Multiple incidents where new config fields weren't shared correctly because we forgot to update the extraction whitelist.
**Solution:** Refactored `extractConfigFields` to use blacklist approach (exclude derived fields) instead of whitelist (manually include everything).
**Result:** New config fields now automatically work in shared worksheets without touching extraction code.
### 2025-01: Total Calculation Bug
**Problem:** Shared 100-page worksheets displayed as 4 pages because validation defaulted `total` to 20 instead of calculating from `problemsPerPage × pages`.
**Solution:** Calculate `total` from primary state instead of using fallback:
```typescript
// Before (bug)
const total = formState.total ?? 20;
// After (fix)
const total = problemsPerPage * pages;
```
**Root Cause:** `extractConfigFields` didn't save `total` (correctly, as it's derived), but validation incorrectly treated it as primary state.

View File

@@ -1,285 +0,0 @@
# Worksheet Grading System - Post-Mortem
**Date:** 2025-11-10
**Status:** Failed implementation - needs redesign
## What Went Wrong
### 1. **Built Too Much At Once**
- Attempted to implement 7+ features simultaneously:
- OpenAI GPT-5 Responses API integration
- Server-Sent Events (SSE) streaming
- Socket.IO real-time progress
- Database schema for attempts/mastery
- Image upload handling
- Result extraction and validation
- Progress UI with phases
**Problem:** No way to test each piece individually. When something broke, impossible to isolate the issue.
### 2. **No Incremental Testing**
- User had no way to test components as they were built
- No debug UI or test harness
- First time user saw anything was when the entire system was "done"
- By then, too many layers of abstraction to debug
### 3. **Insufficient Visibility**
- No way to see raw API responses
- No way to test socket connections independently
- No way to verify event parsing
- Logs were buried in server console
### 4. **Wrong Development Order**
Built from bottom-up instead of outside-in:
1. Started with database schema
2. Added API integration
3. Added streaming/sockets
4. Finally built UI
**Should have been:**
1. Build minimal UI with mock data
2. Add real API calls (non-streaming)
3. Add streaming/progress
4. Add database persistence
### 5. **API Response Structure Misunderstanding**
- GPT-5 Responses API returns `output[0]` = reasoning, `output[1]` = message
- Didn't discover this until after everything was "working"
- Result was JSON string that needed parsing
- These are fundamental issues that should have been caught early with a test harness
### 6. **Scope Creep**
Started with "grade a worksheet" and ended up with:
- Mastery tracking system
- Progression path logic
- Retry mechanism with validation errors
- Multiple upload modes (file/camera/QR)
- Real-time streaming progress
- Socket.IO infrastructure
**Should have started with:** "Can we call OpenAI and get back problem grades?"
## Root Cause
**I built a production system without first proving the concept worked.**
The user couldn't give feedback on each component because there was no way to interact with them individually. By the time integration was done, the feedback was "this is total garbage" because debugging was impossible.
## What Should Have Happened
### **Phase 1: Proof of Concept (Day 1)**
**Goal:** Prove we can call OpenAI and get worksheet grades
**Deliverable:** `/worksheets/debug/api-test` page with:
- Upload image button
- "Call OpenAI" button
- Raw request display (with image truncated)
- Raw response display
- Parsed result display
- Clear error messages
**User can verify:**
- ✅ Image uploads work
- ✅ OpenAI API responds
- ✅ Response structure is correct
- ✅ We can extract problem grades
**Exit criteria:** User uploads a real worksheet and sees correct grades displayed.
---
### **Phase 2: Result Validation (Day 2)**
**Goal:** Ensure OpenAI returns valid, usable data
**Add to debug page:**
- Schema validation results
- Field-by-field validation
- Test multiple worksheets
- Edge case handling (no problems visible, blurry, etc.)
**User can verify:**
- ✅ Validation logic works
- ✅ Retry mechanism works
- ✅ Error messages are helpful
**Exit criteria:** 10 test worksheets all grade correctly with valid output.
---
### **Phase 3: Storage (Day 3)**
**Goal:** Save grading results to database
**Add:**
- Database tables for attempts
- API route to save results
- Display saved results
**Debug page shows:**
- "Save to DB" button
- Database insert confirmation
- Link to view saved result
**User can verify:**
- ✅ Results persist correctly
- ✅ Can retrieve and display saved grades
**Exit criteria:** User can reload page and see their saved grading results.
---
### **Phase 4: Progress UI (Day 4)**
**Goal:** Add streaming progress updates
**First:** Add Socket.IO test page
- Connect/disconnect buttons
- Emit test events
- Display all received events
- Connection status
**User can verify:**
- ✅ Socket connections work
- ✅ Events are received
- ✅ Disconnections are handled
**Then:** Add streaming to debug page
- Toggle streaming on/off
- Display events as they arrive
- Compare streaming vs non-streaming
**Exit criteria:** User sees token counts updating in real-time.
---
### **Phase 5: Production UI (Day 5+)**
Only after all pieces work individually:
- Build upload page
- Add camera capture
- Add results page
- Add mastery tracking
Each piece can reference working debug pages if something breaks.
## Key Principles for Next Attempt
### 1. **Build Testable Components**
Every major component should have a dedicated test/debug page:
- `/worksheets/debug/api-test` - OpenAI API calls
- `/worksheets/debug/socket-test` - Socket.IO connections
- `/worksheets/debug/upload-test` - Image upload handling
- `/worksheets/debug/stream-test` - SSE stream parsing
### 2. **Outside-In Development**
Start with UI/UX and work backward:
1. What does the user see?
2. What API does that need?
3. What database tables does that need?
4. What external services does that need?
### 3. **One Feature at a Time**
Each PR should add exactly ONE user-facing capability:
- "User can upload image and see raw API response"
- "User can see parsed problem grades"
- "User can save and reload grading results"
- "User sees real-time progress updates"
### 4. **Give User Control**
Every test page should have buttons/controls for:
- Triggering actions manually
- Viewing raw data
- Testing edge cases
- Comparing approaches (streaming vs non-streaming)
### 5. **Make Debugging Easy**
- All API calls should be logged with request/response
- Socket events should be visible in UI
- Database queries should be logged
- Error messages should include full context
### 6. **Get Feedback Early**
Show the user working pieces BEFORE integrating them:
- "Here's the API response - does this look right?"
- "Here's the socket connection - do you see events?"
- "Here's the progress UI - is this what you wanted?"
## Technical Lessons Learned
### OpenAI GPT-5 Responses API
- Response structure: `output[0]` = reasoning, `output[1]` = message
- Message content is a JSON string that needs parsing
- Streaming uses SSE with custom event types
- `json_schema` with `strict: true` enforces exact schema match
### Socket.IO with Next.js
- Must specify correct path: `/api/socket`
- Server and client must match paths
- Events don't queue - client must connect before server emits
### Streaming Challenges
- Node.js fetch has default timeouts
- Need AbortController for custom timeouts
- SSE parsing library (eventsource-parser) has version-specific API
- Variable scoping issues in async error handlers
## Revised Specification for Next Attempt
See `WORKSHEET_GRADING_SPEC_V2.md` (to be written)
Key changes:
- Start with non-streaming
- Build debug pages first
- One deliverable per phase
- User tests each phase before next phase starts
- No integration until all pieces work independently
## Conclusion
**The implementation failed because there was no way to test it incrementally.**
The correct approach is to build small, testable pieces that the user can interact with and give feedback on. Only after each piece is proven to work should we integrate them together.
Next time:
1. Build a test page first
2. Get user feedback
3. Iterate on that page until it works perfectly
4. Only then integrate into production

View File

@@ -1,478 +0,0 @@
# Worksheet Grading System - Specification v2
**Status:** Not implemented - awaiting future development
**Created:** 2025-11-10
**Replaces:** Failed v1 implementation
## Core Goal
**Enable teachers to upload photos of completed math worksheets and get automated grading with problem-by-problem feedback.**
## Development Approach
**Build test pages first, production pages second.**
Every feature must have a debug/test page where the user can:
- Trigger the feature manually
- See raw data/responses
- Test edge cases
- Verify it works before integration
## Phase 1: API Proof of Concept
**Goal:** Prove OpenAI can grade worksheets
### Deliverable: `/worksheets/debug/openai-test` page
**UI Components:**
- Image upload input
- "Test OpenAI API" button
- Toggle: "Streaming" vs "Simple"
- Display sections:
- Request details (model, tokens, etc.)
- Raw API response (collapsible JSON)
- Parsed grades table
- Validation errors (if any)
- Timing information
**Functionality:**
```typescript
// Non-streaming first
async function testOpenAI(imageFile: File) {
const response = await fetch("/api/debug/test-openai", {
method: "POST",
body: formData,
});
// Display raw response
// Display parsed grades
// Display any errors
}
```
**Success Criteria:**
- User uploads worksheet photo
- Sees raw OpenAI response
- Sees parsed problem grades
- All grades are correct
- Edge cases handled (blurry, no problems, etc.)
**Blocked by:** Nothing - can start immediately
---
## Phase 2: Socket.IO Infrastructure
**Goal:** Prove real-time communication works
### Deliverable: `/worksheets/debug/socket-test` page
**UI Components:**
- Connection status indicator
- "Connect" / "Disconnect" buttons
- "Send Test Event" button
- Event log (scrollable, timestamped)
- Latency meter
**Functionality:**
```typescript
function SocketTest() {
const [events, setEvents] = useState([]);
const [socket, setSocket] = useState(null);
function connect() {
const s = io({ path: "/api/socket" });
s.on("connect", () => addEvent("Connected"));
s.on("test-event", (data) => addEvent("Received", data));
setSocket(s);
}
function sendTest() {
socket.emit("test-event", {
timestamp: Date.now(),
message: "Hello from client",
});
}
}
```
**Success Criteria:**
- Socket connects successfully
- Test events are sent and received
- Latency is acceptable (<100ms)
- Reconnection works after disconnect
- No events are lost
**Blocked by:** Nothing - independent of Phase 1
---
## Phase 3: Streaming Progress
**Goal:** Add real-time progress to OpenAI calls
### Enhancement to Phase 1 page
**Add to `/worksheets/debug/openai-test`:**
- Progress bar with phases
- Token counter (live updates)
- Event log showing SSE events
- Comparison: "With Progress" vs "Without Progress"
**Functionality:**
```typescript
async function testStreamingOpenAI(imageFile: File) {
const response = await fetch("/api/debug/test-openai-stream", {
method: "POST",
body: formData,
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// Parse SSE events
// Update progress UI
// Display in event log
}
}
```
**Success Criteria:**
- User sees progress bar update in real-time
- Token counts increase smoothly
- All SSE event types are handled
- Final result matches non-streaming result
- No connection timeouts
**Blocked by:** Phase 1 (need working OpenAI integration)
---
## Phase 4: Database Persistence
**Goal:** Save and retrieve grading results
### Deliverable: `/worksheets/debug/storage-test` page
**UI Components:**
- "Save Result to DB" button
- Saved results list (with IDs)
- "Load Result" button for each saved result
- Display: saved vs current result comparison
**Database Schema:**
```sql
CREATE TABLE worksheet_attempts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
uploaded_image_url TEXT NOT NULL,
grading_status TEXT NOT NULL, -- 'pending', 'processing', 'completed', 'failed'
total_problems INTEGER,
correct_count INTEGER,
accuracy REAL,
error_patterns TEXT, -- JSON array
suggested_step_id TEXT,
ai_feedback TEXT,
graded_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE problem_attempts (
id TEXT PRIMARY KEY,
attempt_id TEXT NOT NULL REFERENCES worksheet_attempts(id),
problem_index INTEGER NOT NULL,
operand_a INTEGER NOT NULL,
operand_b INTEGER NOT NULL,
correct_answer INTEGER NOT NULL,
student_answer INTEGER,
is_correct BOOLEAN NOT NULL,
error_type TEXT, -- 'computation', 'carry', 'alignment', etc.
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Success Criteria:**
- Results save to database
- Can retrieve saved results by ID
- Data integrity is maintained
- Can query by user/date
- No data loss
**Blocked by:** Phase 1 (need working grades to save)
---
## Phase 5: Socket + Streaming Integration
**Goal:** Combine real-time progress with Socket.IO
### Enhancement to Phase 3
**Modify `/worksheets/debug/openai-test`:**
- Add "Use Socket.IO" checkbox
- When enabled, progress updates emit via socket
- Multiple browser tabs can watch same grading
- Compare: HTTP streaming vs Socket.IO
**Server logic:**
```typescript
// Server-side during grading
io.emit("grading:progress", {
attemptId,
phase: "analyzing",
inputTokens: 1234,
outputTokens: 567,
message: "Analyzing problems...",
});
```
**Client logic:**
```typescript
socket.on("grading:progress", (data) => {
if (data.attemptId === currentAttemptId) {
updateProgressUI(data);
}
});
```
**Success Criteria:**
- Socket progress updates work alongside SSE
- Multiple clients can watch same grading
- Progress is smooth and accurate
- No race conditions
- Handles client disconnect/reconnect
**Blocked by:** Phases 2, 3 (need both working independently)
---
## Phase 6: Production Upload Page
**Goal:** Real user-facing upload interface
### Deliverable: `/worksheets/upload` page
**UI Components:**
- Three upload modes:
- File picker
- Camera capture
- QR code (advanced)
- Preview of uploaded image
- "Submit for Grading" button
- Redirect to results page
**Functionality:**
- Validates image (size, format)
- Uploads to server
- Creates attempt record
- Starts grading process
- Redirects to `/worksheets/attempts/[id]`
**Success Criteria:**
- All three upload modes work
- Image validation works
- Error messages are clear
- Loading states are shown
- Mobile camera works
**Blocked by:** Phases 1, 4 (need API and storage)
---
## Phase 7: Results Display Page
**Goal:** Show grading results to user
### Deliverable: `/worksheets/attempts/[attemptId]` page
**UI Components:**
- Overall stats (X/Y correct, accuracy %)
- Problem-by-problem table:
- Problem (e.g., "45 + 27")
- Correct answer
- Student answer
- Status (✓ or ✗)
- Error type (if incorrect)
- AI feedback text
- Suggested next practice level
- "Grade Another" button
**Real-time Updates:**
- Shows progress while grading
- Updates when grading completes
- Shows errors if grading fails
**Success Criteria:**
- Results display correctly
- Real-time updates work
- Can handle pending/processing states
- Error states are clear
- Links to suggested practice
**Blocked by:** Phases 4, 5, 6 (need storage, progress, upload)
---
## Phase 8: Mastery Tracking (Optional)
**Goal:** Track student progress over time
### Deliverable: `/worksheets/progress` page
**Features:**
- List of all attempts
- Progress chart over time
- Skill breakdown
- Weak areas identification
**Database:**
```sql
CREATE TABLE mastery_profiles (
user_id TEXT PRIMARY KEY,
current_step_id TEXT NOT NULL,
mastery_score REAL NOT NULL,
attempts_at_step INTEGER DEFAULT 0,
updated_at TIMESTAMP
);
```
**Success Criteria:**
- Can view progress over time
- Mastery score is accurate
- Recommended next step is helpful
**Blocked by:** Phases 1-7 (need full system working)
---
## Development Principles
### 1. **Test Pages First**
Every feature has a `/worksheets/debug/*` test page before production page.
### 2. **One Phase at a Time**
Complete each phase fully before starting the next. Get user approval before proceeding.
### 3. **Independent Components**
Each phase should work standalone. If Phase 5 breaks, Phases 1-4 still work.
### 4. **Raw Data Visibility**
All test pages show:
- Raw requests
- Raw responses
- Parsed data
- Validation results
- Timing information
### 5. **Manual Control**
User can trigger every action manually from test pages. No automatic background processing until it's proven to work.
### 6. **Clear Exit Criteria**
Each phase has explicit success criteria. User must verify before moving on.
## Technical Stack
**Core Technologies:**
- OpenAI GPT-5 Responses API (vision + reasoning)
- Socket.IO for real-time updates
- SSE for streaming progress
- SQLite + Drizzle ORM for storage
- Next.js App Router for UI
**Key Libraries:**
- `socket.io` / `socket.io-client` - Real-time communication
- `eventsource-parser` (maybe) - SSE parsing if needed
- Standard Next.js/React
## Migration from V1
**Files to keep:**
- Database schema (worksheet_attempts, problem_attempts, mastery_profiles)
- Basic OpenAI integration (non-streaming)
**Files to remove/rewrite:**
- Streaming implementation (too complex, not tested)
- Socket progress system (built wrong order)
- Results page (built before API worked)
**Files to create:**
- `/worksheets/debug/openai-test`
- `/worksheets/debug/socket-test`
- `/worksheets/debug/storage-test`
## Success Metrics
**After Phase 1:** User can grade a worksheet via test page
**After Phase 4:** User can save and reload results
**After Phase 7:** User can upload → grade → view results (full flow)
**After Phase 8:** User can track progress over time
## Timeline Estimate
- Phase 1: 2-4 hours
- Phase 2: 1-2 hours
- Phase 3: 2-3 hours
- Phase 4: 2-3 hours
- Phase 5: 2-3 hours
- Phase 6: 2-3 hours
- Phase 7: 2-3 hours
- Phase 8: 4-6 hours (optional)
**Total:** ~15-25 hours for Phases 1-7
**Key difference from V1:** Each phase is independently testable and verifiable.
## Next Steps
When ready to implement:
1. User: "Start Phase 1"
2. Claude: Builds `/worksheets/debug/openai-test` page
3. User: Tests with real worksheets, provides feedback
4. Iterate until Phase 1 works perfectly
5. Move to Phase 2
**Do not start Phase 2 until Phase 1 is approved by user.**

View File

@@ -1,84 +0,0 @@
# Worksheet Grading System - Current Status
**Date:** 2025-11-10
**Status:** ⚠️ INCOMPLETE - DO NOT USE
## What Exists
The following files/features were partially implemented but **do not work correctly**:
### Database Tables
- `worksheet_attempts` - Stores grading attempts
- `problem_attempts` - Stores individual problem results
- `mastery_profiles` - Tracks student progress
- `worksheet_settings` - User preferences
**Status:** Tables exist but grading logic is broken
### API Routes
- `/api/worksheets/upload` - Upload worksheet images
- `/api/worksheets/attempts/[attemptId]` - Get grading results
**Status:** Upload works, grading is broken
### Library Files
- `src/lib/ai/gradeWorksheet.ts` - OpenAI GPT-5 integration
- `src/lib/grading/processAttempt.ts` - Grading orchestration
- `src/lib/grading/updateMasteryProfile.ts` - Mastery tracking
**Status:** Partially implemented, has bugs, incomplete
### UI Pages
- `/worksheets/attempts/[attemptId]` - View results
**Status:** UI exists but backend doesn't work
## What's Broken
1. **OpenAI Response Parsing** - Wrong output index, JSON parsing issues
2. **Streaming Progress** - Event parsing bugs, connection issues
3. **Socket.IO Integration** - Path configuration, event handling
4. **No Testing Infrastructure** - No way to test components independently
5. **No Debug UI** - No visibility into what's happening
## Why It Failed
**Built too much at once without incremental testing.**
See `WORKSHEET_GRADING_POSTMORTEM.md` for detailed analysis.
## Next Steps
**When ready to tackle this again:**
1. Read `WORKSHEET_GRADING_SPEC_V2.md`
2. Start with Phase 1: Build `/worksheets/debug/openai-test`
3. Get that working perfectly with user feedback
4. Only then move to Phase 2
**Do not attempt to fix the existing implementation.** Start fresh following the new spec.
## Files to Reference
- `WORKSHEET_GRADING_POSTMORTEM.md` - What went wrong and why
- `WORKSHEET_GRADING_SPEC_V2.md` - How to build it correctly next time
- `WORKSHEET_GRADING_STATUS.md` - This file (current status)
## Migrations to Keep
Migrations 0017-0020 created the worksheet tables. These can stay but the application logic needs to be rebuilt from scratch following the new approach.
## Recommendation
**Leave the existing code in place** but don't use it. When ready to implement:
1. Build test pages first (in `/worksheets/debug/`)
2. Get each piece working independently
3. Integrate only after all pieces work
4. Replace the broken production pages
This way we keep the database schema but rebuild the logic correctly.

View File

@@ -11,25 +11,25 @@ This document tracks z-index values and stacking contexts across the application
All z-index values should be defined in this file and imported where needed:
```typescript
import { Z_INDEX } from "../constants/zIndex";
import { Z_INDEX } from '../constants/zIndex'
// Use it like this:
zIndex: Z_INDEX.NAV_BAR;
zIndex: Z_INDEX.MODAL;
zIndex: Z_INDEX.GAME_NAV.HAMBURGER_MENU;
zIndex: Z_INDEX.NAV_BAR
zIndex: Z_INDEX.MODAL
zIndex: Z_INDEX.GAME_NAV.HAMBURGER_MENU
```
## Z-Index Layering Hierarchy
From lowest to highest:
| Layer | Range | Purpose | Examples |
| -------------------------- | ----------- | ------------------------------------------------ | ----------------------------------------------------------------- |
| **Base Content** | 0-99 | Default page content, game elements | Background elements, game tracks, cards |
| **Navigation & UI Chrome** | 100-999 | Fixed navigation, sticky headers | AppNavBar, page headers |
| **Overlays & Dropdowns** | 1000-9999 | Tooltips, popovers, dropdowns, tutorial tooltips | Tutorial tooltips (50-100), ConfigForm (50), dropdowns (999-1000) |
| **Modals & Dialogs** | 10000-19999 | Modal dialogs, confirmation dialogs | Modal backdrop (10000), Modal content (10001) |
| **Top-Level Overlays** | 20000+ | Toasts, critical notifications | Toast notifications (20000) |
| Layer | Range | Purpose | Examples |
|-------|-------|---------|----------|
| **Base Content** | 0-99 | Default page content, game elements | Background elements, game tracks, cards |
| **Navigation & UI Chrome** | 100-999 | Fixed navigation, sticky headers | AppNavBar, page headers |
| **Overlays & Dropdowns** | 1000-9999 | Tooltips, popovers, dropdowns, tutorial tooltips | Tutorial tooltips (50-100), ConfigForm (50), dropdowns (999-1000) |
| **Modals & Dialogs** | 10000-19999 | Modal dialogs, confirmation dialogs | Modal backdrop (10000), Modal content (10001) |
| **Top-Level Overlays** | 20000+ | Toasts, critical notifications | Toast notifications (20000) |
## Stacking Context Rules
@@ -70,78 +70,77 @@ If Element A creates a stacking context with `z-index: 1` and Element B is outsi
### ✅ Using Z_INDEX Constants (Good!)
| Component | Value | Source |
| ------------------------- | ---------------------------------------------------- | --------------------------------------------- |
| AppNavBar (Panda section) | `Z_INDEX.NAV_BAR` (100) | `src/components/AppNavBar.tsx:464` |
| AppNavBar hamburger | `Z_INDEX.GAME_NAV.HAMBURGER_MENU` (9999) | `src/components/AppNavBar.tsx:165` |
| AbacusDisplayDropdown | `Z_INDEX.GAME_NAV.HAMBURGER_NESTED_DROPDOWN` (10000) | `src/components/AbacusDisplayDropdown.tsx:99` |
| Component | Value | Source |
|-----------|-------|--------|
| AppNavBar (Panda section) | `Z_INDEX.NAV_BAR` (100) | `src/components/AppNavBar.tsx:464` |
| AppNavBar hamburger | `Z_INDEX.GAME_NAV.HAMBURGER_MENU` (9999) | `src/components/AppNavBar.tsx:165` |
| AbacusDisplayDropdown | `Z_INDEX.GAME_NAV.HAMBURGER_NESTED_DROPDOWN` (10000) | `src/components/AbacusDisplayDropdown.tsx:99` |
### ⚠️ Hardcoded Z-Index Values (Need Migration)
#### Critical Navigation Issues
| Component | Line | Value | Issue | Fix |
| ----------------------------- | ---- | ------ | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| **AppNavBar (fixed section)** | 587 | `1000` | ❌ Should use `Z_INDEX.NAV_BAR` (100), but increased to 1000 to fix tutorial tooltip overlap | Define `TUTORIAL_TOOLTIP` in constants, set nav to proper layer |
| AppNavBar (badge) | 645 | `50` | Should use constant | Add `Z_INDEX.BADGE` |
| Component | Line | Value | Issue | Fix |
|-----------|------|-------|-------|-----|
| **AppNavBar (fixed section)** | 587 | `1000` | ❌ Should use `Z_INDEX.NAV_BAR` (100), but increased to 1000 to fix tutorial tooltip overlap | Define `TUTORIAL_TOOLTIP` in constants, set nav to proper layer |
| AppNavBar (badge) | 645 | `50` | Should use constant | Add `Z_INDEX.BADGE` |
#### Tutorial System
| Component | Line | Value | Purpose |
| -------------------------------- | ------------- | ------------ | ---------------------------------------- |
| TutorialPlayer | 643 | `50` | Tooltip container |
| Tutorial shared/EditorComponents | 569, 590 | `50` | Tooltip button |
| Tutorial shared/EditorComponents | 612 | `100` | Dropdown content (must be above tooltip) |
| Tutorial decomposition CSS | 73 | `50` | Legacy CSS |
| TutorialEditor | 65, 812, 2339 | `1000`, `10` | Various overlays |
| Component | Line | Value | Purpose |
|-----------|------|-------|---------|
| TutorialPlayer | 643 | `50` | Tooltip container |
| Tutorial shared/EditorComponents | 569, 590 | `50` | Tooltip button |
| Tutorial shared/EditorComponents | 612 | `100` | Dropdown content (must be above tooltip) |
| Tutorial decomposition CSS | 73 | `50` | Legacy CSS |
| TutorialEditor | 65, 812, 2339 | `1000`, `10` | Various overlays |
#### Modals & Overlays
| Component | Line | Value | Purpose |
| --------------- | ---------- | ---------------- | -------------------------------------- |
| Modal (common) | 59 | `10000` | Modal backdrop |
| ModerationPanel | 1994, 2009 | `10001`, `10002` | Moderation overlays |
| ToastContext | 171 | `10001` | Toast notifications (should be 20000!) |
| Join page | 35 | `10000` | Join page overlay |
| EmojiPicker | 636 | `10000` | Emoji picker modal |
| Component | Line | Value | Purpose |
|-----------|------|-------|---------|
| Modal (common) | 59 | `10000` | Modal backdrop |
| ModerationPanel | 1994, 2009 | `10001`, `10002` | Moderation overlays |
| ToastContext | 171 | `10001` | Toast notifications (should be 20000!) |
| Join page | 35 | `10000` | Join page overlay |
| EmojiPicker | 636 | `10000` | Emoji picker modal |
#### Dropdowns & Popovers
| Component | Line | Value | Purpose |
| ------------------- | -------- | --------------- | ----------------- |
| FormatSelectField | 115 | `999` | Dropdown |
| DeploymentInfoModal | 37, 55 | `9998`, `9999` | Info modal layers |
| RoomInfo | 338, 562 | `9999`, `10000` | Room tooltips |
| GameTitleMenu | 119 | `9999` | Game menu |
| PlayerTooltip | 69 | `9999` | Player tooltip |
| Component | Line | Value | Purpose |
|-----------|------|-------|---------|
| FormatSelectField | 115 | `999` | Dropdown |
| DeploymentInfoModal | 37, 55 | `9998`, `9999` | Info modal layers |
| RoomInfo | 338, 562 | `9999`, `10000` | Room tooltips |
| GameTitleMenu | 119 | `9999` | Game menu |
| PlayerTooltip | 69 | `9999` | Player tooltip |
#### Game Elements
| Component | Line | Value | Purpose |
| ------------------------ | ----------------------- | ------------------------- | ------------------------ |
| Complement Race Game | Multiple | `0`, `1` | Base game layers |
| Complement Race Track | 118, 140, 151 | `10`, `5`, `20` | Track, AI racers, player |
| Complement Race HUD | 51, 106, 119, 137, 168 | `10`, `1000` | HUD elements |
| GameCountdown | 58 | `1000` | Countdown overlay |
| RouteCelebration | 31 | `9999` | Celebration overlay |
| Matching GameCard | 203, 229, 243, 272, 386 | `9`, `10`, `-1`, `8`, `1` | Card layers |
| Matching PlayerStatusBar | 154, 181, 202 | `10`, `10`, `5` | Status bars |
| Component | Line | Value | Purpose |
|-----------|------|-------|---------|
| Complement Race Game | Multiple | `0`, `1` | Base game layers |
| Complement Race Track | 118, 140, 151 | `10`, `5`, `20` | Track, AI racers, player |
| Complement Race HUD | 51, 106, 119, 137, 168 | `10`, `1000` | HUD elements |
| GameCountdown | 58 | `1000` | Countdown overlay |
| RouteCelebration | 31 | `9999` | Celebration overlay |
| Matching GameCard | 203, 229, 243, 272, 386 | `9`, `10`, `-1`, `8`, `1` | Card layers |
| Matching PlayerStatusBar | 154, 181, 202 | `10`, `10`, `5` | Status bars |
#### Misc UI
| Component | Line | Value | Purpose |
| ---------------------- | ----------------------- | ------------------------- | ------------------- |
| HeroAbacus | 89, 127, 163 | `10` | Hero section layers |
| ChampionArena | 425, 514, 554, 614 | `10`, `1`, `1`, `10` | Arena layers |
| NetworkPlayerIndicator | 118, 145, 169, 192, 275 | `-1`, `2`, `1`, `2`, `10` | Player avatars |
| ConfigurationForm | 521, 502 | `50` | Config overlays |
| Component | Line | Value | Purpose |
|-----------|------|-------|---------|
| HeroAbacus | 89, 127, 163 | `10` | Hero section layers |
| ChampionArena | 425, 514, 554, 614 | `10`, `1`, `1`, `10` | Arena layers |
| NetworkPlayerIndicator | 118, 145, 169, 192, 275 | `-1`, `2`, `1`, `2`, `10` | Player avatars |
| ConfigurationForm | 521, 502 | `50` | Config overlays |
## The Recent Bug: Tutorial Tooltips Over Nav Bar
**Problem:** Tutorial tooltips (z-index: 50, 100) were appearing over the navigation bar.
**Root Cause:**
- Nav bar was using `Z_INDEX.NAV_BAR` = 100 in one place
- But also hardcoded `zIndex: 30` in the fixed positioning section (line 587)
- Tutorial tooltips use hardcoded `zIndex: 50` and `zIndex: 100`
@@ -150,7 +149,6 @@ If Element A creates a stacking context with `z-index: 1` and Element B is outsi
**Temporary Fix:** Increased nav bar's hardcoded value from 30 to 1000
**Proper Fix Needed:**
1. Define tutorial tooltip z-indexes in constants file
2. Update nav bar to consistently use `Z_INDEX.NAV_BAR`
3. Ensure NAV_BAR > TUTORIAL_TOOLTIP in the hierarchy
@@ -162,11 +160,11 @@ If Element A creates a stacking context with `z-index: 1` and Element B is outsi
```typescript
// ✅ Good
import { Z_INDEX } from "../constants/zIndex";
zIndex: Z_INDEX.NAV_BAR;
import { Z_INDEX } from '../constants/zIndex'
zIndex: Z_INDEX.NAV_BAR
// ❌ Bad
zIndex: 100; // Magic number!
zIndex: 100 // Magic number!
```
### 2. **Add New Values to Constants File First**
@@ -178,16 +176,15 @@ export const Z_INDEX = {
// ... existing values ...
TUTORIAL: {
TOOLTIP: 500, // Tutorial tooltips (overlays layer)
DROPDOWN: 600, // Tutorial dropdown (above tooltip)
TOOLTIP: 500, // Tutorial tooltips (overlays layer)
DROPDOWN: 600, // Tutorial dropdown (above tooltip)
},
} as const;
} as const
```
### 3. **Choose the Right Layer**
Ask yourself:
- Is this base content? → Use 0-99
- Is this navigation/UI chrome? → Use 100-999
- Is this a dropdown/tooltip/overlay? → Use 1000-9999
@@ -197,7 +194,6 @@ Ask yourself:
### 4. **Understand Your Stacking Context**
Before setting z-index, ask:
- What is my parent's stacking context?
- Am I comparing against siblings or global elements?
- Does my element create a new stacking context?
@@ -209,7 +205,7 @@ If you must deviate from the constants, document why:
```typescript
// HACK: Needs to be above tutorial tooltips (50) but below modals (10000)
// TODO: Migrate to Z_INDEX.TUTORIAL.TOOLTIP system
zIndex: 100;
zIndex: 100
```
## Migration Plan
@@ -223,7 +219,7 @@ export const Z_INDEX = {
// Base content layer (0-99)
BASE: 0,
CONTENT: 1,
HERO_SECTION: 10, // Hero abacus components
HERO_SECTION: 10, // Hero abacus components
// Game content layers (0-99)
GAME_CONTENT: {
@@ -236,15 +232,15 @@ export const Z_INDEX = {
},
// Navigation and UI chrome (100-999)
NAV_BAR: 1000, // ⚠️ Currently needs to be 1000 due to tutorial tooltips
NAV_BAR: 1000, // ⚠️ Currently needs to be 1000 due to tutorial tooltips
STICKY_HEADER: 100,
BADGE: 50,
// Overlays and dropdowns (1000-9999)
TUTORIAL: {
TOOLTIP: 500, // Tutorial tooltips
DROPDOWN: 600, // Tutorial dropdowns (must be > tooltip)
EDITOR: 700, // Tutorial editor
TOOLTIP: 500, // Tutorial tooltips
DROPDOWN: 600, // Tutorial dropdowns (must be > tooltip)
EDITOR: 700, // Tutorial editor
},
DROPDOWN: 1000,
TOOLTIP: 1000,
@@ -271,13 +267,12 @@ export const Z_INDEX = {
HAMBURGER_MENU: 9999,
HAMBURGER_NESTED_DROPDOWN: 10000,
},
} as const;
} as const
```
### Phase 2: Migrate High-Priority Components
Priority order:
1. **Navigation components** (AppNavBar, etc.) - most critical for user experience
2. **Tutorial system** (TutorialPlayer, tooltips) - currently conflicting
3. **Modals and overlays** - ensure they're always on top
@@ -325,13 +320,11 @@ When elements aren't layering correctly:
### DevTools Tips
**Chrome DevTools:**
1. Open DevTools → More Tools → Layers
2. Select an element and see its stacking context
3. View the 3D layer composition
**Firefox DevTools:**
1. Inspector → Layout → scroll to "Z-index"
2. Shows the stacking context parent

View File

@@ -1,573 +0,0 @@
# Plan: Factor Out DecompositionContext
## Goal
Create a standalone `DecompositionContext` that can be used anywhere in the app where we want to show an interactive decomposition display for an abacus problem. The context only needs `startValue` and `targetValue` as inputs and provides all the derived data and interaction handlers.
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ DecompositionProvider │
│ Input: startValue, targetValue │
│ Optional: currentStepIndex, onSegmentChange, onTermHover │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ generateUnifiedInstructionSequence(start, target) │ │
│ │ → fullDecomposition, segments, steps, termPositions │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Derived Functions │ │
│ │ • getColumnFromTermIndex(termIndex) │ │
│ │ • getTermIndicesFromColumn(columnIndex) │ │
│ │ • getGroupTermIndicesFromTermIndex(termIndex) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Interactive State │ │
│ │ • activeTermIndices: Set<number> │ │
│ │ • activeIndividualTermIndex: number | null │ │
│ │ • handleTermHover, handleColumnHover │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ TutorialPlayer │ │ Practice Help │ │ Future Uses │
│ │ │ Panel │ │ (Flashcards, │
│ Wraps with │ │ │ │ Games, etc.) │
│ Provider, │ │ Wraps term help │ │ │
│ syncs step │ │ with Provider │ │ │
└───────────────┘ └─────────────────┘ └─────────────────┘
```
## Implementation Steps
### Step 1: Create DecompositionContext
**File:** `src/contexts/DecompositionContext.tsx`
```typescript
'use client'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import {
generateUnifiedInstructionSequence,
type PedagogicalSegment,
type UnifiedInstructionSequence,
type UnifiedStepData,
} from '@/utils/unifiedStepGenerator'
// ============================================================================
// Types
// ============================================================================
export interface DecompositionContextConfig {
/** Starting abacus value */
startValue: number
/** Target abacus value to reach */
targetValue: number
/** Current step index for highlighting (optional) */
currentStepIndex?: number
/** Callback when active segment changes (optional) */
onSegmentChange?: (segment: PedagogicalSegment | null) => void
/** Callback when a term is hovered (optional) */
onTermHover?: (termIndex: number | null, columnIndex: number | null) => void
/** Number of abacus columns for column mapping (default: 5) */
abacusColumns?: number
}
export interface DecompositionContextType {
// Generated data
sequence: UnifiedInstructionSequence
fullDecomposition: string
isMeaningfulDecomposition: boolean
termPositions: Array<{ startIndex: number; endIndex: number }>
segments: PedagogicalSegment[]
steps: UnifiedStepData[]
// Configuration
startValue: number
targetValue: number
currentStepIndex: number
abacusColumns: number
// Highlighting state
activeTermIndices: Set<number>
setActiveTermIndices: (indices: Set<number>) => void
activeIndividualTermIndex: number | null
setActiveIndividualTermIndex: (index: number | null) => void
// Derived functions
getColumnFromTermIndex: (termIndex: number, useGroupColumn?: boolean) => number | null
getTermIndicesFromColumn: (columnIndex: number) => number[]
getGroupTermIndicesFromTermIndex: (termIndex: number) => number[]
// Event handlers
handleTermHover: (termIndex: number, isHovering: boolean) => void
handleColumnHover: (columnIndex: number, isHovering: boolean) => void
}
// ============================================================================
// Context
// ============================================================================
const DecompositionContext = createContext<DecompositionContextType | null>(null)
export function useDecomposition(): DecompositionContextType {
const context = useContext(DecompositionContext)
if (!context) {
throw new Error('useDecomposition must be used within a DecompositionProvider')
}
return context
}
// Optional hook that returns null if not in provider (for conditional usage)
export function useDecompositionOptional(): DecompositionContextType | null {
return useContext(DecompositionContext)
}
// ============================================================================
// Provider
// ============================================================================
interface DecompositionProviderProps extends DecompositionContextConfig {
children: ReactNode
}
export function DecompositionProvider({
startValue,
targetValue,
currentStepIndex = 0,
onSegmentChange,
onTermHover,
abacusColumns = 5,
children,
}: DecompositionProviderProps) {
// -------------------------------------------------------------------------
// Generate sequence (memoized on value changes)
// -------------------------------------------------------------------------
const sequence = useMemo(
() => generateUnifiedInstructionSequence(startValue, targetValue),
[startValue, targetValue]
)
// -------------------------------------------------------------------------
// Highlighting state
// -------------------------------------------------------------------------
const [activeTermIndices, setActiveTermIndices] = useState<Set<number>>(new Set())
const [activeIndividualTermIndex, setActiveIndividualTermIndex] = useState<number | null>(null)
// -------------------------------------------------------------------------
// Derived: term positions from steps
// -------------------------------------------------------------------------
const termPositions = useMemo(
() => sequence.steps.map((step) => step.termPosition),
[sequence.steps]
)
// -------------------------------------------------------------------------
// Derived function: Get column index from term index
// -------------------------------------------------------------------------
const getColumnFromTermIndex = useCallback(
(termIndex: number, useGroupColumn = false): number | null => {
const step = sequence.steps[termIndex]
if (!step?.provenance) return null
// For group highlighting: use rhsPlace (target column of the operation)
// For individual term: use termPlace (specific column this term affects)
const placeValue = useGroupColumn
? step.provenance.rhsPlace
: (step.provenance.termPlace ?? step.provenance.rhsPlace)
// Convert place value to column index (rightmost column is highest index)
return abacusColumns - 1 - placeValue
},
[sequence.steps, abacusColumns]
)
// -------------------------------------------------------------------------
// Derived function: Get term indices that affect a given column
// -------------------------------------------------------------------------
const getTermIndicesFromColumn = useCallback(
(columnIndex: number): number[] => {
const placeValue = abacusColumns - 1 - columnIndex
return sequence.steps
.map((step, index) => ({ step, index }))
.filter(({ step }) => {
if (!step.provenance) return false
return (
step.provenance.rhsPlace === placeValue ||
step.provenance.termPlace === placeValue
)
})
.map(({ index }) => index)
},
[sequence.steps, abacusColumns]
)
// -------------------------------------------------------------------------
// Derived function: Get all term indices in the same complement group
// -------------------------------------------------------------------------
const getGroupTermIndicesFromTermIndex = useCallback(
(termIndex: number): number[] => {
const step = sequence.steps[termIndex]
if (!step?.provenance) return [termIndex]
const groupId = step.provenance.groupId
if (!groupId) return [termIndex]
// Find all steps with the same groupId
return sequence.steps
.map((s, i) => ({ step: s, index: i }))
.filter(({ step: s }) => s.provenance?.groupId === groupId)
.map(({ index }) => index)
},
[sequence.steps]
)
// -------------------------------------------------------------------------
// Event handler: Term hover
// -------------------------------------------------------------------------
const handleTermHover = useCallback(
(termIndex: number, isHovering: boolean) => {
if (isHovering) {
// Set individual term highlight
setActiveIndividualTermIndex(termIndex)
// Set group highlights
const groupIndices = getGroupTermIndicesFromTermIndex(termIndex)
setActiveTermIndices(new Set(groupIndices))
// Notify external listener
if (onTermHover) {
const columnIndex = getColumnFromTermIndex(termIndex, true)
onTermHover(termIndex, columnIndex)
}
} else {
setActiveIndividualTermIndex(null)
setActiveTermIndices(new Set())
onTermHover?.(null, null)
}
},
[getGroupTermIndicesFromTermIndex, getColumnFromTermIndex, onTermHover]
)
// -------------------------------------------------------------------------
// Event handler: Column hover (for bidirectional abacus ↔ decomposition)
// -------------------------------------------------------------------------
const handleColumnHover = useCallback(
(columnIndex: number, isHovering: boolean) => {
if (isHovering) {
const termIndices = getTermIndicesFromColumn(columnIndex)
setActiveTermIndices(new Set(termIndices))
} else {
setActiveTermIndices(new Set())
}
},
[getTermIndicesFromColumn]
)
// -------------------------------------------------------------------------
// Effect: Notify when active segment changes
// -------------------------------------------------------------------------
useEffect(() => {
if (!onSegmentChange) return
const segment = sequence.segments.find((seg) =>
seg.stepIndices?.includes(currentStepIndex)
)
onSegmentChange(segment || null)
}, [currentStepIndex, sequence.segments, onSegmentChange])
// -------------------------------------------------------------------------
// Context value
// -------------------------------------------------------------------------
const value: DecompositionContextType = useMemo(
() => ({
// Generated data
sequence,
fullDecomposition: sequence.fullDecomposition,
isMeaningfulDecomposition: sequence.isMeaningfulDecomposition,
termPositions,
segments: sequence.segments,
steps: sequence.steps,
// Configuration
startValue,
targetValue,
currentStepIndex,
abacusColumns,
// Highlighting state
activeTermIndices,
setActiveTermIndices,
activeIndividualTermIndex,
setActiveIndividualTermIndex,
// Derived functions
getColumnFromTermIndex,
getTermIndicesFromColumn,
getGroupTermIndicesFromTermIndex,
// Event handlers
handleTermHover,
handleColumnHover,
}),
[
sequence,
termPositions,
startValue,
targetValue,
currentStepIndex,
abacusColumns,
activeTermIndices,
activeIndividualTermIndex,
getColumnFromTermIndex,
getTermIndicesFromColumn,
getGroupTermIndicesFromTermIndex,
handleTermHover,
handleColumnHover,
]
)
return (
<DecompositionContext.Provider value={value}>
{children}
</DecompositionContext.Provider>
)
}
```
### Step 2: Create Standalone DecompositionDisplay Component
**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";
import { useDecomposition } from "@/contexts/DecompositionContext";
import { ReasonTooltip } from "./ReasonTooltip"; // Moved here
import "./decomposition.css";
export function DecompositionDisplay() {
const {
fullDecomposition,
termPositions,
segments,
steps,
currentStepIndex,
activeTermIndices,
activeIndividualTermIndex,
handleTermHover,
getGroupTermIndicesFromTermIndex,
} = useDecomposition();
// ... rendering logic (adapted from DecompositionWithReasons)
}
```
### Step 3: Refactor SegmentGroup
Pass `steps` as a prop instead of reading from TutorialContext:
```typescript
// Before:
function SegmentGroup({ segment, ... }) {
const { unifiedSteps: steps } = useTutorialContext()
// ...
}
// After:
function SegmentGroup({ segment, steps, ... }) {
// steps comes from props (DecompositionDisplay passes it from context)
}
```
### 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
### Step 5: Update TutorialPlayer Integration
**File:** `src/components/tutorial/TutorialPlayer.tsx`
Wrap the decomposition area with `DecompositionProvider`:
```typescript
// In TutorialPlayer:
<DecompositionProvider
startValue={currentStep.startValue}
targetValue={currentStep.targetValue}
currentStepIndex={currentMultiStep}
onSegmentChange={(segment) => ui.setActiveSegment(segment)}
onTermHover={(termIndex, columnIndex) => {
// Update abacus column highlighting
setHighlightedColumn(columnIndex)
}}
>
<div data-element="decomposition-container">
<DecompositionDisplay />
</div>
</DecompositionProvider>
```
### Step 6: Integrate into Practice Help Panel
**File:** `src/components/practice/ActiveSession.tsx`
Add decomposition to the help panel:
```typescript
{/* Per-term help panel */}
{helpTermIndex !== null && helpContext && (
<div data-section="term-help">
{/* Header and dismiss button ... */}
{/* NEW: Decomposition display */}
<DecompositionProvider
startValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
currentStepIndex={currentHelpStepIndex}
>
<div data-element="decomposition-container">
<DecompositionDisplay />
</div>
</DecompositionProvider>
{/* Existing: Provenance breakdown */}
{/* Existing: HelpAbacus */}
</div>
)}
```
## File Structure After Refactoring
```
src/
├── contexts/
│ └── DecompositionContext.tsx # NEW: Standalone context
├── components/
│ ├── decomposition/ # NEW: Shared decomposition components
│ │ ├── DecompositionDisplay.tsx
│ │ ├── TermSpan.tsx
│ │ ├── SegmentGroup.tsx
│ │ ├── ReasonTooltip.tsx # MOVED from tutorial/
│ │ ├── decomposition.css # MOVED from tutorial/
│ │ └── index.ts # Re-exports
│ │
│ ├── tutorial/
│ │ ├── TutorialPlayer.tsx # UPDATED: Uses DecompositionProvider
│ │ ├── TutorialContext.tsx # SIMPLIFIED: Remove decomposition logic
│ │ └── ...
│ │
│ └── practice/
│ ├── ActiveSession.tsx # UPDATED: Uses DecompositionProvider
│ └── ...
```
## 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
## Testing Checklist
### Tutorial Mode
- [ ] Decomposition shows correctly for each step
- [ ] Current step is highlighted
- [ ] Term hover shows tooltip
- [ ] Term hover highlights related terms
- [ ] Term hover highlights abacus column
- [ ] 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
- [ ] Dark mode styling correct
- [ ] No console errors
### Edge Cases
- [ ] Single-digit addition (no meaningful decomposition)
- [ ] Multi-column carries
- [ ] Complement operations (five/ten complements)
- [ ] Very large numbers
- [ ] Empty/invalid values handled gracefully
## 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 |
## 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)
Recommend starting with Option A for simplicity, optimize later if needed.

View File

@@ -1,65 +0,0 @@
# Progressive Help Overlay Feature Plan
## Executive Summary
**What:** When kid enters a prefix sum, show interactive abacus covering completed terms with time-based hint escalation.
**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
## Visual Layout
```
11 ← covered by abacus
+ 1 ← covered by abacus
+ 1 ← covered by abacus
┌─────────────────┐
│ ABACUS: 13→33 │ ← positioned above next term
└─────────────────┘
+ 20 ← term being added (visible)
+ 10 ← remaining terms (visible)
──────────
… [ 13 ]
```
## 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 |
## Shared Infrastructure (Already Exists)
- `generateUnifiedInstructionSequence()` - step/segment data
- `DecompositionProvider` / `DecompositionDisplay` - visual decomposition
- `generateDynamicCoachHint()` - context-aware hints
- `HelpAbacus` - interactive abacus with arrows
## To Extract from TutorialPlayer
- `findTopmostBeadWithArrows()` - bead selection
- `calculateTooltipSide()` - smart collision detection
- `createTooltipTarget()` - overlay target creation
## 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 |
## Deferred
Positioning challenge (fixed abacus height vs variable prefix terms) - handle in follow-up.

View File

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

View File

@@ -1,80 +1,112 @@
{
"permissions": {
"allow": [
"Bash(xargs:*)",
"Bash(npx @biomejs/biome lint:*)",
"Bash(npm test:*)",
"Read(//Users/antialias/projects/**)",
"Bash(npm run lint:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(npm run type-check:*)",
"Bash(npm run pre-commit:*)",
"Bash(gh run list:*)",
"Bash(ssh:*)",
"Bash(git fetch:*)",
"Bash(npx tsc:*)",
"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(npx @biomejs/biome check:*)",
"Bash(npx vitest:*)",
"Bash(node -e:*)",
"Bash(npm test:*)",
"Bash(npx @biomejs/biome format:*)",
"Bash(npm run lint:*)",
"Bash(git rebase:*)",
"Bash(git pull:*)",
"Bash(git stash:*)",
"Bash(git stash pop:*)",
"Bash(npx drizzle-kit:*)",
"Bash(npm run db:migrate:*)",
"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(npm run format:*)",
"Bash(npm run pre-commit:*)",
"Bash(npm run type-check:*)",
"Bash(npm run build:*)",
"Bash(docker ps:*)",
"Bash(cat:*)",
"Bash(npm run dev:*)",
"Bash(git mv:*)",
"Bash(git rm:*)",
"Bash(docker build:*)",
"Read(//Users/antialias/**)",
"Bash(docker logs:*)",
"Bash(curl:*)",
"Bash(docker stop:*)",
"Bash(docker rm:*)",
"Bash(docker run:*)",
"Bash(docker rmi:*)",
"Bash(gh run list:*)",
"Bash(gh run view:*)",
"Bash(timeout 15 pnpm run dev:*)",
"Bash(npx tsc:*)",
"Bash(npx biome format:*)",
"Bash(npx biome check:*)",
"Bash(npx @biomejs/biome lint:*)",
"Bash(test -f /Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/hooks/__tests__/useArcadeGuard.test.ts)",
"Bash(timeout 30 npm test -- AddPlayerButton.popover-persistence.test.tsx --run)",
"Bash(timeout 30 npm test:*)",
"Bash(xargs:*)",
"Bash(for file in page.tsx practice/page.tsx sprint/page.tsx survival/page.tsx)",
"Bash(do)",
"Bash(done)",
"Bash(npx playwright test:*)",
"Bash(npm run:*)",
"Bash(\"\")",
"Bash(npx @biomejs/biome check:*)",
"Bash(printf '\\n')",
"Bash(npm install bcryptjs)",
"Bash(npm install:*)",
"Bash(pnpm add:*)",
"Bash(npx tsx:*)",
"Bash(sqlite3:*)",
"Bash(shasum:*)",
"Bash(awk:*)",
"Bash(if npx tsc --noEmit)",
"Bash(then echo \"TypeScript errors found in our files\")",
"Bash(else echo \"✓ No TypeScript errors in our modified files\")",
"Bash(fi)",
"Bash(then echo \"TypeScript errors found\")",
"Bash(else echo \"✓ No TypeScript errors in join page\")",
"Bash(npx @biomejs/biome format:*)",
"Bash(npx drizzle-kit generate:*)",
"Bash(ssh nas.home.network \"docker ps | grep -E ''soroban|abaci|web''\")",
"Bash(ssh:*)",
"Bash(printf \"\\n\\n\")",
"Bash(timeout 10 npx drizzle-kit generate:*)",
"Bash(git checkout:*)",
"Bash(git log:*)",
"Bash(python3:*)",
"Bash(git reset:*)",
"Bash(lsof:*)",
"Bash(killall:*)",
"Bash(echo:*)",
"Bash(git restore:*)",
"Bash(timeout 10 npm run dev:*)",
"Bash(timeout 30 npm run dev)",
"Bash(pkill:*)",
"Bash(for i in {1..30})",
"Bash(do gh run list --limit 1 --json conclusion,status,name,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run ID: \\(.databaseId)\"\"')",
"Bash(tsc:*)",
"Bash(tsc-alias:*)",
"Bash(npx tsc-alias:*)",
"Bash(timeout 20 pnpm run:*)",
"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:*)"
"Bash(for:*)",
"Bash(tree:*)",
"Bash(do sed -i '' \"s|from ''../context/MemoryPairsContext''|from ''../Provider''|g\" \"$file\")",
"Bash(do sed -i '' \"s|from ''../../../../../styled-system/css''|from ''@/styled-system/css''|g\" \"$file\")",
"Bash(tee:*)",
"Bash(do sed -i '' \"s|from ''@/styled-system/css''|from ''../../../../styled-system/css''|g\" \"$file\")",
"Bash(do echo \"=== $game ===\" echo \"Required files:\" ls -1 src/arcade-games/$game/)",
"Bash(do echo \"=== $game%/ ===\")",
"Bash(ls:*)",
"Bash(do if [ -f \"$file\" ])",
"Bash(! echo \"$file\")",
"Bash(then sed -i '' \"s|from ''''../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" sed -i '' \"s|from ''''../../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" fi done)",
"Bash(pnpm install)",
"Bash(pnpm exec turbo build --filter=@soroban/web)",
"Bash(do gh run list --limit 1 --json conclusion,status,name,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')",
"Bash(do gh run list --limit 1 --json conclusion,status,name --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - \\(.name)\"\"')",
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')",
"WebFetch(domain:abaci.one)",
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run ID: \\(.databaseId)\"\"')",
"Bash(node -e:*)",
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run \\(.databaseId)\"\"')",
"Bash(do ssh nas.home.network '/usr/local/bin/docker inspect soroban-abacus-flashcards --format=\"\"{{index .Config.Labels \\\"\"org.opencontainers.image.revision\\\"\"}}\"\"')",
"Bash(git rev-parse HEAD)",
"Bash(gh run watch --exit-status 18662351595)"
],
"deny": [],
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": ["sqlite"]
}
}

View File

@@ -1 +0,0 @@
# Docker build test

View File

@@ -30,12 +30,7 @@ const config: StorybookConfig = {
if (config.resolve) {
config.resolve.alias = {
...config.resolve.alias,
// Map @styled/* imports (from tsconfig paths)
'@styled/css': join(__dirname, '../styled-system/css/index.mjs'),
'@styled/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
'@styled/jsx': join(__dirname, '../styled-system/jsx/index.mjs'),
'@styled/recipes': join(__dirname, '../styled-system/recipes/index.mjs'),
// Map relative styled-system imports
// Map styled-system imports to the actual directory
'../../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
'../../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
'../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),

View File

@@ -0,0 +1,15 @@
import type { Preview } from '@storybook/nextjs'
import '../styled-system/styles.css'
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
}
export default preview

View File

@@ -1,36 +0,0 @@
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: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
decorators: [
(Story) => (
<ThemeProvider>
<NextIntlClientProvider locale="en" messages={messages}>
<AbacusDisplayProvider>
<Story />
</AbacusDisplayProvider>
</NextIntlClientProvider>
</ThemeProvider>
),
],
}
export default preview

File diff suppressed because it is too large Load Diff

View File

@@ -1,85 +0,0 @@
# Soroban Web Application
Interactive web application for learning soroban (Japanese abacus) calculation with tutorials, practice sessions, and multiplayer arcade games.
## Features
- **Tutorials** - Step-by-step lessons for learning soroban techniques
- **Practice Sessions** - Adaptive practice with progressive help system
- **Arcade Games** - Multiplayer educational games for reinforcement
- **Worksheet Generator** - Create printable math worksheets
## Getting Started
```bash
# Install dependencies
pnpm install
# Start development server
pnpm dev
# Run type checks
npm run type-check
# Run all quality checks
npm run pre-commit
```
## Documentation
### Components
| 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 |
### 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 |
### Developer Documentation
Located in `.claude/` directory:
- `CLAUDE.md` - Project conventions and guidelines
- `CODE_QUALITY_REGIME.md` - Quality check procedures
- `GAME_SETTINGS_PERSISTENCE.md` - Game config architecture
- `Z_INDEX_MANAGEMENT.md` - Z-index layering system
- `DEPLOYMENT.md` - Deployment and CI/CD
## Project Structure
```
apps/web/
├── src/
│ ├── app/ # Next.js App Router pages
│ ├── components/ # Shared React components
│ │ ├── decomposition/ # Math decomposition display
│ │ ├── practice/ # Practice session components
│ │ └── tutorial/ # Tutorial player components
│ ├── contexts/ # React context providers
│ ├── arcade-games/ # Multiplayer game implementations
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utilities and libraries
│ └── db/ # Database schema and queries
├── .claude/ # Developer documentation
├── public/ # Static assets
└── styled-system/ # Generated Panda CSS
```
## Technology Stack
- **Framework**: Next.js 14 (App Router)
- **Language**: TypeScript
- **Styling**: Panda CSS
- **Database**: SQLite with Drizzle ORM
- **Abacus Visualization**: @soroban/abacus-react
## Related Documentation
**Parent**: [Main README](../../README.md) - Complete project overview
**Abacus Component**: [packages/abacus-react](../../packages/abacus-react/README.md) - Abacus visualization library

View File

@@ -1,437 +0,0 @@
/**
* @vitest-environment node
*/
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '../src/db'
import { createRoom } from '../src/lib/arcade/room-manager'
import { createInvitation, getInvitation } from '../src/lib/arcade/room-invitations'
import { addRoomMember } from '../src/lib/arcade/room-membership'
/**
* Join Flow with Invitation Acceptance E2E Tests
*
* Tests the bug fix for invitation acceptance:
* - When a user joins a restricted room with an invitation
* - The invitation should be marked as "accepted"
* - This prevents the invitation from showing up again
*
* Regression test for the bug where invitations stayed "pending" forever.
*/
describe('Join Flow: Invitation Acceptance', () => {
let hostUserId: string
let guestUserId: string
let hostGuestId: string
let guestGuestId: string
let roomId: string
beforeEach(async () => {
// Create test users
hostGuestId = `test-host-${Date.now()}-${Math.random().toString(36).slice(2)}`
guestGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [host] = await db.insert(schema.users).values({ guestId: hostGuestId }).returning()
const [guest] = await db.insert(schema.users).values({ guestId: guestGuestId }).returning()
hostUserId = host.id
guestUserId = guest.id
})
afterEach(async () => {
// Clean up invitations
if (roomId) {
await db.delete(schema.roomInvitations).where(eq(schema.roomInvitations.roomId, roomId))
}
// Clean up room
if (roomId) {
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, roomId))
}
// Clean up users
await db.delete(schema.users).where(eq(schema.users.id, hostUserId))
await db.delete(schema.users).where(eq(schema.users.id, guestUserId))
})
describe('BUG FIX: Invitation marked as accepted after join', () => {
it('marks invitation as accepted when guest joins restricted room', async () => {
// 1. Host creates a restricted room
const room = await createRoom({
name: 'Restricted Room',
createdBy: hostGuestId,
creatorName: 'Host User',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted', // Requires invitation
})
roomId = room.id
// 2. Host invites guest
const invitation = await createInvitation({
roomId,
userId: guestUserId,
userName: 'Guest User',
invitedBy: hostUserId,
invitedByName: 'Host User',
invitationType: 'manual',
})
// 3. Verify invitation is pending
expect(invitation.status).toBe('pending')
// 4. Guest joins the room (simulating the join API flow)
// In the real API, it checks the invitation and then adds the member
const invitationCheck = await getInvitation(roomId, guestUserId)
expect(invitationCheck?.status).toBe('pending')
// Simulate what the join API does: add member
await addRoomMember({
roomId,
userId: guestGuestId,
displayName: 'Guest User',
isCreator: false,
})
// 5. BUG: Before fix, invitation would still be "pending" here
// AFTER FIX: The join API now explicitly marks it as "accepted"
// Simulate the fix from join API
const { acceptInvitation } = await import('../src/lib/arcade/room-invitations')
await acceptInvitation(invitation.id)
// 6. Verify invitation is now marked as accepted
const updatedInvitation = await getInvitation(roomId, guestUserId)
expect(updatedInvitation?.status).toBe('accepted')
expect(updatedInvitation?.respondedAt).toBeDefined()
})
it('prevents showing the same invitation again after accepting', async () => {
// This tests the exact bug scenario from the issue:
// "even if I accept the invite and join the room,
// if I try to join room SFK3GD again, then I'm shown the same invite notice"
// 1. Create Room A and Room B
const roomA = await createRoom({
name: 'Room KHS3AE',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted',
})
const roomB = await createRoom({
name: 'Room SFK3GD',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'open', // Guest can join without invitation
})
roomId = roomA.id // For cleanup
// 2. Invite guest to Room A
const invitationA = await createInvitation({
roomId: roomA.id,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
})
// 3. Guest sees invitation to Room A
const { getUserPendingInvitations } = await import('../src/lib/arcade/room-invitations')
let pendingInvites = await getUserPendingInvitations(guestUserId)
expect(pendingInvites).toHaveLength(1)
expect(pendingInvites[0].roomId).toBe(roomA.id)
// 4. Guest accepts and joins Room A
const { acceptInvitation } = await import('../src/lib/arcade/room-invitations')
await acceptInvitation(invitationA.id)
await addRoomMember({
roomId: roomA.id,
userId: guestGuestId,
displayName: 'Guest',
isCreator: false,
})
// 5. Guest tries to visit Room B link (/join/SFK3GD)
// BUG: Before fix, they'd see Room A invitation again because it's still "pending"
// FIX: Invitation is now "accepted", so it won't show in pending list
pendingInvites = await getUserPendingInvitations(guestUserId)
expect(pendingInvites).toHaveLength(0) // ✅ No longer shows Room A
// 6. Guest can successfully join Room B without being interrupted
await addRoomMember({
roomId: roomB.id,
userId: guestGuestId,
displayName: 'Guest',
isCreator: false,
})
// Clean up
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, roomB.id))
})
})
describe('Invitation flow with multiple rooms', () => {
it('only shows pending invitations, not accepted ones', async () => {
// Create 3 rooms, invite to all of them
const room1 = await createRoom({
name: 'Room 1',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted',
})
const room2 = await createRoom({
name: 'Room 2',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted',
})
const room3 = await createRoom({
name: 'Room 3',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted',
})
roomId = room1.id // For cleanup
// Invite to all 3
const inv1 = await createInvitation({
roomId: room1.id,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
})
const inv2 = await createInvitation({
roomId: room2.id,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
})
const inv3 = await createInvitation({
roomId: room3.id,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
})
// All 3 should be pending
const { getUserPendingInvitations, acceptInvitation } = await import(
'../src/lib/arcade/room-invitations'
)
let pending = await getUserPendingInvitations(guestUserId)
expect(pending).toHaveLength(3)
// Accept invitation 1 and join
await acceptInvitation(inv1.id)
// Now only 2 should be pending
pending = await getUserPendingInvitations(guestUserId)
expect(pending).toHaveLength(2)
expect(pending.map((p) => p.roomId)).not.toContain(room1.id)
// Clean up
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id))
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room3.id))
})
})
describe('Host re-joining their own restricted room', () => {
it('host can rejoin without invitation (no acceptance needed)', async () => {
// Create restricted room as host
const room = await createRoom({
name: 'Host Room',
createdBy: hostGuestId,
creatorName: 'Host User',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted',
})
roomId = room.id
// Host joins their own room
await addRoomMember({
roomId,
userId: hostGuestId,
displayName: 'Host User',
isCreator: true,
})
// No invitation needed, no acceptance
// This should not create any invitation records
const invitation = await getInvitation(roomId, hostUserId)
expect(invitation).toBeUndefined()
})
})
describe('Edge cases', () => {
it('handles multiple invitations from same host to same guest (updates, not duplicates)', async () => {
const room = await createRoom({
name: 'Test Room',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted',
})
roomId = room.id
// Send first invitation
const inv1 = await createInvitation({
roomId,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
message: 'First message',
})
// Send second invitation (should update, not create new)
const inv2 = await createInvitation({
roomId,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
message: 'Second message',
})
// Should be same invitation (same ID)
expect(inv1.id).toBe(inv2.id)
expect(inv2.message).toBe('Second message')
// Should only have 1 invitation in database
const allInvitations = await db
.select()
.from(schema.roomInvitations)
.where(eq(schema.roomInvitations.roomId, roomId))
expect(allInvitations).toHaveLength(1)
})
it('re-sends invitation after previous was declined', async () => {
const room = await createRoom({
name: 'Test Room',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted',
})
roomId = room.id
// First invitation
const inv1 = await createInvitation({
roomId,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
})
// Guest declines
const { declineInvitation, getUserPendingInvitations } = await import(
'../src/lib/arcade/room-invitations'
)
await declineInvitation(inv1.id)
// Should not be in pending list
let pending = await getUserPendingInvitations(guestUserId)
expect(pending).toHaveLength(0)
// Host sends new invitation (should reset to pending)
await createInvitation({
roomId,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
})
// Should now be in pending list again
pending = await getUserPendingInvitations(guestUserId)
expect(pending).toHaveLength(1)
expect(pending[0].status).toBe('pending')
})
it('accepts invitations to OPEN rooms (not just restricted)', async () => {
// This tests the root cause of the bug:
// Invitations to OPEN rooms were never being marked as accepted
const openRoom = await createRoom({
name: 'Open Room',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'open', // Open access - no invitation required to join
})
roomId = openRoom.id
// Host sends invitation anyway (e.g., to notify guest about the room)
const inv = await createInvitation({
roomId: openRoom.id,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
})
// Guest should see pending invitation
const { getUserPendingInvitations, acceptInvitation } = await import(
'../src/lib/arcade/room-invitations'
)
let pending = await getUserPendingInvitations(guestUserId)
expect(pending).toHaveLength(1)
// Guest joins the open room (invitation not required, but present)
await addRoomMember({
roomId: openRoom.id,
userId: guestGuestId,
displayName: 'Guest',
isCreator: false,
})
// Simulate the join API accepting the invitation
await acceptInvitation(inv.id)
// BUG FIX: Invitation should now be accepted, not stuck in pending
pending = await getUserPendingInvitations(guestUserId)
expect(pending).toHaveLength(0) // ✅ No longer pending
// Verify it's marked as accepted
const acceptedInv = await getInvitation(openRoom.id, guestUserId)
expect(acceptedInv?.status).toBe('accepted')
})
})
})

View File

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

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",

View File

@@ -1,212 +0,0 @@
---
title: "Beyond Easy and Hard: A 2D Approach to Worksheet Difficulty"
description: "Most educational software uses a simple 1D difficulty slider. We built something better: a constrained 2D space that separates problem complexity from instructional support."
author: "Abaci.one Team"
publishedAt: "2025-11-07"
updatedAt: "2025-11-07"
tags: ["education", "difficulty", "pedagogy", "soroban", "worksheets"]
featured: true
---
# Beyond Easy and Hard: A 2D Approach to Worksheet Difficulty
Most educational software treats difficulty as a one-dimensional slider: easy → medium → hard. But anyone who's taught students knows that difficulty is more nuanced than that.
We've built a new approach for our addition worksheet generator that treats difficulty as **two independent dimensions**: problem complexity (Challenge) and instructional support (Support). And critically, we constrain the combinations to only those that are pedagogically valid.
Here's why this matters and how it works.
## The Problem with 1D Difficulty
Imagine you're a teacher working with two students:
**Student A**: Ready for harder problems with multi-digit regrouping, but still benefits from visual aids like ten-frames and place value colors.
**Student B**: Comfortable working independently without scaffolding, but struggles with complex regrouping and needs simpler problems.
With a traditional "easy/medium/hard" system, you're stuck:
- Setting difficulty to "hard" gives Student A complex problems... but removes all the visual support they still need
- Setting it to "easy" gives Student B the scaffolding-free experience they want... but the problems are too simple
**You can't express "hard problems with visual aids" or "easy problems without scaffolding"** because difficulty conflates two completely different things: the intrinsic complexity of the problem and the amount of instructional support provided.
## Our Solution: Challenge × Support
We split difficulty into two independent dimensions:
### Challenge Axis (Regrouping Complexity)
How complex is the problem itself?
- **Low**: Simple addition, no carrying (23 + 15)
- **Medium**: Some regrouping in ones or tens place (47 + 38)
- **High**: Frequent regrouping across multiple place values (587 + 798)
This is **intrinsic cognitive load** — the inherent difficulty of the problem regardless of how it's presented.
### Support Axis (Scaffolding Level)
How much instructional support is shown?
- **High support**: Carry boxes, answer boxes, place value colors, ten-frames
- **Medium support**: Carry boxes when needed, colors for larger numbers
- **Low support**: Minimal or no scaffolding, student works independently
This is **extraneous cognitive load** — the mental effort required by how the problem is presented and supported.
## But Here's the Crucial Part: Constraints
Not all combinations of Challenge and Support are pedagogically valid.
**High challenge + High support** doesn't work well. If you're giving students complex multi-digit regrouping problems but showing them every step with maximum scaffolding, you're preventing them from developing problem-solving strategies. They're just following the scaffolds, not thinking.
**Low challenge + Low support** is pointless practice. If the problems are trivially simple and you're not providing any instructional structure, students aren't learning anything new.
So we constrain the space to a **diagonal band** of valid combinations:
```
Support (Scaffolding) →
Low Medium High
Challenge High ✓ ✓ ✗
(Regrouping) ✓ ✓ ✓
Medium ✗ ✓ ✓
✗ ✗ ✓
Low ✗ ✓ ✓
```
**As challenge increases, support must decrease** (and vice versa). This encodes a fundamental pedagogical principle: students learning new concepts need support, but as they master the concept, support should fade.
### Visual Examples
Here's what this looks like in practice. Below are actual worksheet examples showing **the same problem complexity** (problems with moderate regrouping) but with **different levels of scaffolding**:
#### Full Scaffolding
![Worksheet with full scaffolding](/blog/difficulty-examples/full-scaffolding.svg)
_Maximum visual support: carry boxes always shown, answer boxes, place value colors, and ten-frames for every step._
#### Medium Scaffolding
![Worksheet with medium scaffolding](/blog/difficulty-examples/medium-scaffolding.svg)
_Strategic support: carry boxes appear when regrouping occurs, answer boxes provided, place value colors for 3+ digit numbers._
#### Minimal Scaffolding
![Worksheet with minimal scaffolding](/blog/difficulty-examples/minimal-scaffolding.svg)
_Minimal scaffolding: carry boxes only for complex problems with multiple regroups, no answer boxes or colors._
#### No Scaffolding
![Worksheet with no scaffolding](/blog/difficulty-examples/no-scaffolding.svg)
_Zero scaffolding: students work completely independently with no visual aids._
Notice how the **problem complexity stays constant** (all use the same regrouping probability), but the **scaffolding progressively fades**. This demonstrates how support can be adjusted independently from problem difficulty, allowing teachers to precisely target their students' needs.
## Theoretical Foundation
This isn't just intuition — it maps to established learning theory:
**Zone of Proximal Development** (Vygotsky): The diagonal band represents the learnable space. Too easy = already mastered. Too hard without support = beyond reach. The valid combinations are where learning happens.
**Cognitive Load Theory** (Sweller): Effective instruction balances intrinsic load (problem complexity) and extraneous load (instructional design). Our constraints prevent overload from either source.
**Scaffolding Fading** (Wood, Bruner, Ross): Temporary supports should be gradually removed as competence develops. The constraint band enforces this fading principle.
## How Teachers Use It
The UI provides three ways to adjust difficulty:
### 1. Default: "Make Harder" / "Make Easier"
The main buttons adjust **both dimensions** simultaneously, moving diagonally through the valid space toward appropriate preset levels (Beginner → Early Learner → Intermediate → Advanced → Expert).
This is the simple, no-thought-required option that works for most cases.
### 2. Challenge-Only Adjustment
Click the dropdown arrow, select "More challenge" or "Less challenge".
This moves **horizontally** — changing problem complexity while maintaining current scaffolding level.
**Use case**: Student A above. They're ready for harder problems but still need the visual aids. Click "More challenge" to increase regrouping while keeping support constant.
### 3. Support-Only Adjustment
Click the dropdown arrow, select "More support" or "Less support".
This moves **vertically** — changing scaffolding level while maintaining current problem complexity.
**Use case**: Student B above. They understand the concepts and don't need the training wheels anymore. Click "Less support" to remove scaffolding while keeping problems at the same complexity.
## Implementation Details
Under the hood, we use a **hybrid discrete/continuous architecture**:
- **Discrete indices** for navigation: 19 regrouping levels (0-18), 13 scaffolding levels (0-12)
- **Continuous scores** for visualization: Calculated on-the-fly for the difficulty graph and preset detection
- **Constraint validation** at every step: The system auto-corrects invalid states
This gives us:
- Predictable, testable behavior (discrete states)
- Smooth visualization (continuous scores)
- Guaranteed pedagogical validity (constraint enforcement)
Each preset profile (Beginner/Intermediate/etc.) is a specific (challenge, support) coordinate in the valid space. The "Make Harder" button finds the nearest harder preset and navigates toward it, automatically adjusting both dimensions as needed.
## Try It Yourself
The system is live at **[abaci.one/create/worksheets/addition](https://abaci.one/create/worksheets/addition)**.
Try these scenarios:
1. **Start at Beginner**, click "Make Harder" repeatedly → watch it move diagonally through the space
2. **Start at Intermediate**, use the dropdown to select "More challenge" only → see problems get harder while keeping visual aids
3. **Start at Early Learner**, use "Less support" → watch scaffolding disappear while problem complexity stays constant
4. **Click on the 2D graph** (the orange debug visualization) → jump directly to any valid difficulty point
The graph shows:
- Gray diagonal band: Valid pedagogical combinations
- Colored dots: Preset profiles (B=Beginner, I=Intermediate, etc.)
- Blue cross: Your current position
- Click anywhere to jump there (system auto-corrects to nearest valid point)
## Why This Matters
Traditional 1D difficulty forces teachers into a one-size-fits-all progression. Every student moves along the same path from "easy" to "hard", regardless of their individual needs.
**Our 2D constrained space enables precise differentiation**:
- Students who grasp concepts quickly can reduce support while maintaining challenge
- Students who need more time get continued support while still progressing to harder problems
- Students can move through the space at different angles, not just along a single path
And because the constraints encode pedagogical principles, teachers can't accidentally create nonsensical combinations. The system guides them toward valid instructional choices.
## What's Next
This is currently implemented for addition worksheets, but the approach generalizes:
- Subtraction, multiplication, division
- Other domains entirely (reading comprehension, programming exercises, etc.)
- Any learning task where you can separate intrinsic difficulty from instructional support
The code is **open source**: [github.com/antialias/soroban-abacus-flashcards](https://github.com/antialias/soroban-abacus-flashcards)
Technical details: [SMART_DIFFICULTY_SPEC.md](https://github.com/antialias/soroban-abacus-flashcards/blob/main/apps/web/src/app/create/worksheets/addition/SMART_DIFFICULTY_SPEC.md)
## Feedback Welcome
We'd love to hear from educators using this system:
- Does the 2D model match your mental model of difficulty?
- Are the dimension-specific controls useful?
- What other domains would benefit from this approach?
Reach out via [GitHub issues](https://github.com/antialias/soroban-abacus-flashcards/issues) or try the system and let us know what you think.
---
_This post describes research-in-progress. We're exploring publication in learning sciences venues (ACM Learning @ Scale, IJAIED). If you're interested in collaboration or want to cite this work, see our [publication plan](https://github.com/antialias/soroban-abacus-flashcards/blob/main/apps/web/src/app/create/worksheets/addition/PUBLICATION_PLAN.md)._

View File

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

View File

@@ -1,249 +0,0 @@
---
title: "Beyond Two Digits: Multi-Digit Arithmetic Worksheets"
description: "Create worksheets with 3, 4, or even 5-digit problems. Smart scaffolding adapts automatically with place value colors and intelligent layout."
author: "Abaci.one Team"
publishedAt: "2025-11-08"
updatedAt: "2025-11-08"
tags: ["worksheets", "multi-digit", "place-value", "scaffolding"]
featured: true
---
# Beyond Two Digits: Multi-Digit Arithmetic Worksheets
Most worksheet generators stop at 2-digit arithmetic. But real mathematical fluency means handling problems of any size with confidence. That's why we've built **multi-digit support** right into our worksheet creator.
## The Challenge of Multi-Digit Arithmetic
When students move from 2-digit to 3+ digit problems, several things get harder:
- **More place values** to track (hundreds, thousands, ten-thousands...)
- **Longer carry/borrow chains** that cascade across multiple columns
- **Mental load** increases exponentially with each additional digit
- **Place value confusion** becomes common ("Was that the hundreds or thousands?")
Traditional worksheets don't adapt - they just throw bigger numbers at students and hope for the best.
We think students deserve better.
## Introducing Adaptive Multi-Digit Worksheets
Our worksheet creator now supports **1 to 5 digits** for both addition and subtraction, with intelligent scaffolding that scales with problem complexity.
### Starting Simple: 2-Digit Baseline
Let's start with familiar 2-digit problems to establish a baseline:
![2-digit addition problems](/blog/multi-digit-examples/two-digit.svg)
Clean, straightforward layout with carry boxes. This is what students are used to.
### Stepping Up: 3-Digit with Place Value Colors
When we add a third digit, the worksheet automatically adapts:
![3-digit addition with place value colors](/blog/multi-digit-examples/three-digit-colors.svg)
**Notice the changes:**
- **Place value colors** now appear: blue (ones), green (tens), yellow (hundreds)
- **Wider grid** accommodates the extra digit
- **Carry boxes** still appear at the bottom for regrouping
- **Same familiar pattern**, just extended
The colors aren't decorative - they're cognitive aids. Students can instantly see "I'm working in the hundreds column" without counting columns or getting lost.
### More Complexity: 4-Digit Problems
As problems get larger, the scaffolding stays consistent:
![4-digit addition](/blog/multi-digit-examples/four-digit.svg)
**New place value**: Pink for thousands
The beauty of this system is **consistency**. Whether it's 2 digits or 4 digits, the pattern is the same:
1. Add the ones (blue)
2. Add the tens (green)
3. Add the hundreds (yellow)
4. Add the thousands (pink)
5. Carry as needed
### Maximum Challenge: 5-Digit Arithmetic
For advanced students, we support up to **5-digit problems**:
![5-digit addition](/blog/multi-digit-examples/five-digit.svg)
**New place value**: Purple for ten-thousands
At this level, students are working with numbers like 48,532 + 61,749. The same scaffolding system that worked for 2-digit problems scales seamlessly:
- **Six place value colors** (including overflow to hundred-thousands in orange)
- **Dynamic grid layout** adjusts to fit the largest problems on the page
- **Carry boxes** track regrouping across multiple columns
- **Answer boxes** maintain consistent spacing
### Mixed Problem Sizes: The Real World
Here's where it gets interesting. In the real world, problems aren't all the same size. So we support **mixed digit ranges**:
![Mixed 2-4 digit problems](/blog/multi-digit-examples/mixed-range.svg)
**What you see:**
- Problem 1: 2-digit (27 + 72)
- Problem 2: 3-digit (568 + 310)
- Problem 3: 4-digit (3,568 + 2,610)
- Problem 4: 2-digit (317 + 42)
**Smart layout:** Each problem takes only as much space as it needs. Place value colors appear only on 3+ digit problems, helping students identify when they need extra attention.
This creates a **progressive difficulty curve** within a single worksheet - perfect for differentiated instruction or spiral review.
## Subtraction Scales Too
Multi-digit support isn't just for addition. Subtraction with borrowing works the same way:
![3-digit subtraction](/blog/multi-digit-examples/three-digit-subtraction.svg)
**Subtraction features:**
- **Borrow notation boxes** scale to any digit count
- **Place value colors** help track which column is borrowing FROM and TO
- **Cascading borrows** (like 1000 1) are handled automatically
## Place Value Color System
Our place value colors follow a consistent pattern across all digit ranges:
| Place Value | Color | Hex |
| --------------------- | ---------- | ------- |
| **Ones** | Light Blue | #BAE6FD |
| **Tens** | Green | #BBF7D0 |
| **Hundreds** | Yellow | #FEF08A |
| **Thousands** | Pink | #FBCFE8 |
| **Ten-Thousands** | Purple | #DDD6FE |
| **Hundred-Thousands** | Orange | #FED7AA |
These colors are:
- **High contrast** for visibility
- **Pastel tones** that don't distract
- **Consistent across all worksheets**
- **Colorblind-friendly** (distinct enough even in grayscale)
## Configuring Digit Ranges
In the worksheet creator, you'll find a **dual-thumb slider** that lets you set digit ranges:
**Fixed size:** Set both thumbs to the same value (e.g., 3-3) for uniform 3-digit problems
**Mixed size:** Set a range (e.g., 2-5) for varied problem sizes
**Examples:**
- `1-1`: Single digit (0-9) - perfect for beginners
- `2-2`: Standard 2-digit (10-99) - classic worksheets
- `3-3`: All 3-digit (100-999) - focused practice
- `2-4`: Mixed 2-4 digit (10-9,999) - progressive difficulty
- `5-5`: Maximum 5-digit (10,000-99,999) - advanced challenge
## Smart Mode: Conditional Scaffolding
One of our most powerful features is **conditional scaffolding based on digit count**:
```
"when3PlusDigits" - Show only on problems with 3 or more digits
```
This means you can create a worksheet that:
- Shows **no place colors** on simple 2-digit problems
- **Automatically adds colors** when problems reach 3+ digits
- Adapts per-problem within the same worksheet
Perfect for differentiated instruction where some students need more support than others.
## How We Handle Overflow
One tricky detail: when you add 99,999 + 99,999, the result is 199,998 - that's **6 digits**, not 5!
Our layout engine automatically accounts for this:
- **Grid columns** expand to accommodate overflow
- **Place value colors** include a 6th color (orange) for hundred-thousands
- **Answer boxes** adjust spacing to fit
Students see that addition can sometimes produce an answer with more digits than the original numbers - an important mathematical insight.
## Progressive Difficulty with Regrouping
You can combine digit range with regrouping difficulty:
**Beginner:** 2-digit, 0% regrouping
```
23 + 45 = 68 (no carries)
```
**Intermediate:** 3-digit, 50% regrouping
```
245 + 378 = 623 (some carries)
```
**Advanced:** 4-digit, 80% regrouping
```
3,456 + 2,789 = 6,245 (multiple carries)
```
**Expert:** 5-digit, 100% regrouping
```
48,532 + 61,749 = 110,281 (maximum complexity)
```
## Practical Use Cases
**Building Mastery:** Start with 2-digit, then 3-digit, then 4-digit worksheets as students progress
**Spiral Review:** Mixed digit ranges (2-4) ensure students don't forget earlier skills
**Challenge Problems:** 5-digit arithmetic for students who need enrichment
**Real-World Context:** Larger numbers appear in real calculations (money, distances, populations)
**Place Value Understanding:** Color-coded columns make abstract place value concrete
## Getting Started
1. Visit the **[Worksheet Creator](/create/worksheets/addition)**
2. Find the **"Problem Size (Digits per Number)"** section
3. Use the slider to set your digit range (1-5)
4. Choose **Smart Mode** for adaptive colors or **Manual Mode** for uniform styling
5. Adjust regrouping difficulty (how many problems involve carrying/borrowing)
6. Toggle place value colors, carry boxes, and answer boxes as needed
7. Generate and download your custom multi-digit worksheet!
## Tips for Teachers
**Start with colors ON** - Most students benefit from place value scaffolding initially
**Gradually fade colors** - As students gain confidence, reduce scaffolding
**Mix digit ranges** - Don't let students get too comfortable with uniform problem sizes
**Use with manipulatives** - Base-10 blocks align perfectly with our color system
**Print in color** - Place value colors work best when actually colored (but design is grayscale-friendly)
**Assess strategically** - Use color-free worksheets to test true mastery without scaffolding
---
Multi-digit arithmetic doesn't have to be intimidating. With the right scaffolding, every student can develop fluency and confidence with numbers of any size.
Happy teaching!
— The Abaci.one Team

View File

@@ -1,246 +0,0 @@
---
title: "Introducing Subtraction Worksheets with Smart Scaffolding"
description: "Create customized subtraction worksheets with borrowing notation, place value colors, and adaptive scaffolding to support every learner."
author: "Abaci.one Team"
publishedAt: "2025-11-08"
updatedAt: "2025-11-08"
tags: ["worksheets", "subtraction", "scaffolding", "borrowing"]
featured: true
---
# Introducing Subtraction Worksheets with Smart Scaffolding
We're excited to announce that our worksheet creator now supports **subtraction problems** with the same intelligent scaffolding system you love from our addition worksheets.
## Why Subtraction Matters
Subtraction with borrowing (also called regrouping) is one of the trickiest concepts in elementary math. Students need to:
- Recognize when borrowing is necessary
- Track which place values are being borrowed FROM and TO
- Write scratch work clearly without losing track
- Manage cascading borrows across multiple place values
Our new subtraction worksheets provide visual scaffolds that make this invisible mental process visible and manageable.
## Scaffolding Options for Subtraction
### Level 1: Simple Subtraction (No Borrowing)
For beginners, start with problems that don't require any borrowing:
![Simple subtraction problems](/blog/subtraction-examples/no-borrowing.svg)
Clean, straightforward layout with answer boxes - perfect for building confidence with basic subtraction. These problems are carefully generated so the top digit is always larger than the bottom digit in each place value.
### Level 2: Introducing Borrowing (Without Scaffolding)
Before adding scaffolding, let's see what borrowing problems look like in their traditional form:
![Subtraction without notation](/blog/subtraction-examples/comparison-no-notation.svg)
**The challenge:** Students must mentally track:
- Which columns need borrowing
- What the modified values become
- Where to write scratch work
- How to avoid crossing out numbers messily
Many students get lost or make careless errors without visual guidance.
### Level 3: Adding Borrow Notation Boxes
Now watch what happens when we add **borrow notation boxes** to the exact same problems:
![Subtraction with borrow notation](/blog/subtraction-examples/comparison-with-notation.svg)
**Immediate improvements:**
- **Dotted scratch boxes** appear to the left of digits that need modification
- **Designated space** for writing the borrowed value (like "12" when borrowing from tens)
- **Visual structure** keeps work organized and legible
- **Less crossing out** - students write in the box instead of over the original number
The problems are identical, but the scaffolding makes the borrowing process visible and manageable.
### Level 4: Single Borrow Focus
For targeted practice on the borrowing mechanism, use problems that require exactly one borrow:
![Single borrow in ones place](/blog/subtraction-examples/single-borrow-ones.svg)
**Place value colors help students see:**
- Blue box = borrowing from the **tens** place to help the **ones** place
- Green tens digit decreases by 1
- Blue ones digit becomes 10 + original value
This focused practice builds the mental model before tackling more complex problems.
### Level 5: Borrowing Hints (Maximum Scaffolding)
For students who need step-by-step guidance, enable **borrowing hints**:
![Detailed borrowing hints](/blog/subtraction-examples/hints-detail.svg)
**Borrowing hints show:**
- **Curved arrows** pointing from the borrow source to the scratch box
- **The calculation** needed (showing "n-1" or the specific transformation)
- **Visual flow** of the borrowing process from left to right
This is particularly powerful when:
- Introducing borrowing for the first time
- Working with students who struggle with the concept
- Providing remedial support
- Creating take-home practice sheets with built-in tutoring
Note: This example uses a single-column layout with only 2 problems so you can see the hints clearly.
### Level 6: Multiple Borrows
Once students master single-column borrowing, challenge them with problems that require borrowing in multiple places:
![Complex subtraction with multiple borrows](/blog/subtraction-examples/multiple-borrows.svg)
**The same scaffolding system scales up:**
- Each place that needs borrowing gets its own notation box
- Place value colors extend to hundreds (yellow), thousands (pink), and beyond
- Students can track multiple borrows without getting overwhelmed
- Problems like 534 178 become manageable
### Level 7: Cascading Borrows (Advanced)
The trickiest type of borrowing is when it **cascades** across multiple place values:
![Cascading borrows across places](/blog/subtraction-examples/cascading-borrows.svg)
**Examples of cascading borrows:**
- 1000 1 requires borrowing through thousands → hundreds → tens → ones
- 5000 2367 creates a chain reaction of borrows
- Each borrow triggers the next, moving from left to right
Our scaffolding handles these complex cases automatically:
- Borrow notation boxes appear wherever needed
- Place value colors show the chain reaction
- Students can work through each step methodically
This is often where students get stuck without proper scaffolding - the cascade is too complex to hold in working memory.
## Smart Mode: Adaptive Scaffolding
Just like addition worksheets, subtraction supports **Smart Mode** where scaffolding automatically adjusts based on problem complexity:
- **No borrowing problems**: Clean layout, no notation boxes
- **Single borrow**: Notation boxes appear only where needed
- **Multiple borrows**: Full scaffolding with place value colors
This means you can create a **single worksheet** that starts easy and progressively increases in difficulty, with scaffolding appearing only when students need it.
## Manual Mode: Full Control
Prefer to control exactly what students see? Use **Manual Mode** to set uniform scaffolding across all problems:
- Toggle borrow notation on/off
- Enable/disable borrowing hints
- Control place value colors
- Show/hide answer boxes
## Teaching Progression: From Beginner to Mastery
Here's how you might use these scaffolding levels to teach subtraction:
**Week 1: Build Confidence**
- Use Level 1 (no borrowing) worksheets
- Focus on basic subtraction mechanics
- Ensure understanding of place value
**Week 2: Introduce Borrowing**
- Show Level 2 (no scaffolding) to highlight the challenge
- Introduce Level 3 (borrow notation boxes)
- Explain: "This is where we'll write our scratch work"
**Week 3: Deepen Understanding**
- Level 4 (single borrow focus) for targeted practice
- Use Level 5 (borrowing hints) for struggling students
- Begin mixed practice with some no-borrow problems
**Week 4: Increase Complexity**
- Level 6 (multiple borrows) for advancing students
- Continue Level 4-5 for students who need more time
- Introduce 3-digit problems
**Week 5-6: Master Cascading Borrows**
- Level 7 (cascading borrows) for ready students
- Use place value colors to show the chain reaction
- Mix all levels for spiral review
**Week 7+: Fade Scaffolding**
- Gradually reduce scaffolding (turn off hints, then notation boxes)
- Smart Mode can automate this transition
- Move toward Level 2 style problems without support
This progression isn't fixed - move faster or slower based on student needs. The key is having the right scaffolding available at each stage.
## Key Features
**✓ Seven scaffolding levels** - Progressive difficulty from no-borrowing through cascading borrows
**✓ Smart borrowing detection** - Automatically identifies which problems require regrouping and where
**✓ Place value colors** - Color-coded columns (ones=blue, tens=green, hundreds=yellow, thousands=pink, ten-thousands=purple)
**✓ Cascading borrow support** - Handles complex chains like 1000 1 and 5000 2367
**✓ Clean scratch work spaces** - Dotted notation boxes provide designated space for modified values
**✓ Optional borrowing hints** - Step-by-step guidance with curved arrows and calculations
**✓ Side-by-side comparison** - Generate identical problems with/without scaffolding to show impact
**✓ Answer boxes** - Clear space for students to write final answers (can be hidden for assessments)
**✓ Flexible layouts** - 1-2 columns, 1-20 problems per page
**✓ Mixed difficulty** - Combine no-borrow and borrow problems on the same worksheet
**✓ PDF export** - Print-ready worksheets for classroom or home use
**✓ 1-5 digit support** - From 2-digit basics to 5-digit advanced problems
## Getting Started
1. Visit the **[Worksheet Creator](/create/worksheets/addition)**
2. Select **"Subtraction Only"** as your operation type
3. Choose your difficulty settings (how many problems require borrowing)
4. Pick **Manual Mode** to control scaffolding or **Smart Mode** for adaptive support
5. Toggle borrow notation, borrowing hints, and place value colors as needed
6. Generate and download your custom worksheet!
## Coming Soon
We're actively working on **Smart Mode rules for subtraction scaffolding**, which will allow conditional display based on:
- Number of borrows in a problem
- Total digit count (2-digit vs 3+ digits)
- Difficulty progression across the worksheet
Stay tuned for updates!
---
Have questions or feedback about subtraction worksheets? We'd love to hear from you at [github.com/anthropics/claude-code/issues](https://github.com/anthropics/claude-code/issues).
Happy teaching!
— The Abaci.one Team

View File

@@ -1,404 +0,0 @@
---
title: "Making the Invisible Visible: Ten-Frames for Teaching Regrouping"
description: "How visual scaffolding with ten-frames helps students understand the 'make ten' strategy in addition with regrouping, and when to fade this support."
author: "Abaci.one Team"
publishedAt: "2025-11-07"
updatedAt: "2025-11-07"
tags:
[
"education",
"ten-frames",
"regrouping",
"pedagogy",
"scaffolding",
"worksheets",
]
featured: true
---
# Making the Invisible Visible: Ten-Frames for Teaching Regrouping
When you ask a child "What is 7 + 5?", they might count on their fingers, use mental strategies, or if they're just learning, stare blankly while their brain tries to process what you're asking. But when you show them ten-frames, something magical happens: the abstract becomes concrete, and the "make ten" strategy becomes obvious.
## What Are Ten-Frames?
A ten-frame is a simple 2×5 rectangular grid—ten boxes arranged in two rows of five. Originally developed for teaching number sense and subitizing (instantly recognizing quantities), ten-frames have become an essential tool for teaching addition, especially when regrouping (carrying) is involved.
<svg width="180" height="90" viewBox="0 0 180 90" xmlns="http://www.w3.org/2000/svg">
<!-- Empty ten-frame -->
<rect x="5" y="5" width="170" height="80" fill="none" stroke="black" stroke-width="2"/>
<line x1="5" y1="45" x2="175" y2="45" stroke="black" stroke-width="1"/>
<line x1="39" y1="5" x2="39" y2="85" stroke="black" stroke-width="1"/>
<line x1="73" y1="5" x2="73" y2="85" stroke="black" stroke-width="1"/>
<line x1="107" y1="5" x2="107" y2="85" stroke="black" stroke-width="1"/>
<line x1="141" y1="5" x2="141" y2="85" stroke="black" stroke-width="1"/>
<text x="90" y="105" font-family="sans-serif" font-size="14" text-anchor="middle">Empty ten-frame</text>
</svg>
The genius of ten-frames is their structure: **two rows of five boxes make visualizing groups of ten natural**. When you see 7 dots in a ten-frame, you immediately see "5 plus 2 more":
<svg width="180" height="110" viewBox="0 0 180 110" xmlns="http://www.w3.org/2000/svg">
<!-- Ten-frame with 7 -->
<rect x="5" y="5" width="170" height="80" fill="none" stroke="black" stroke-width="2"/>
<line x1="5" y1="45" x2="175" y2="45" stroke="black" stroke-width="1"/>
<line x1="39" y1="5" x2="39" y2="85" stroke="black" stroke-width="1"/>
<line x1="73" y1="5" x2="73" y2="85" stroke="black" stroke-width="1"/>
<line x1="107" y1="5" x2="107" y2="85" stroke="black" stroke-width="1"/>
<line x1="141" y1="5" x2="141" y2="85" stroke="black" stroke-width="1"/>
<!-- Top row filled (5) -->
<circle cx="22" cy="25" r="12" fill="#93c5fd"/>
<circle cx="56" cy="25" r="12" fill="#93c5fd"/>
<circle cx="90" cy="25" r="12" fill="#93c5fd"/>
<circle cx="124" cy="25" r="12" fill="#93c5fd"/>
<circle cx="158" cy="25" r="12" fill="#93c5fd"/>
<!-- Bottom row partial (2) -->
<circle cx="22" cy="65" r="12" fill="#93c5fd"/>
<circle cx="56" cy="65" r="12" fill="#93c5fd"/>
<text x="90" y="105" font-family="sans-serif" font-size="14" text-anchor="middle">7 = "5 full + 2 more"</text>
</svg>
When you add 5 more dots and the frame fills up, you physically see the creation of a complete ten, plus extras that don't fit:
<svg width="380" height="110" viewBox="0 0 380 110" xmlns="http://www.w3.org/2000/svg">
<!-- Ten-frame showing 7 + 5 = 12 -->
<!-- First frame (full with 10) -->
<rect x="5" y="5" width="170" height="80" fill="none" stroke="black" stroke-width="2"/>
<line x1="5" y1="45" x2="175" y2="45" stroke="black" stroke-width="1"/>
<line x1="39" y1="5" x2="39" y2="85" stroke="black" stroke-width="1"/>
<line x1="73" y1="5" x2="73" y2="85" stroke="black" stroke-width="1"/>
<line x1="107" y1="5" x2="107" y2="85" stroke="black" stroke-width="1"/>
<line x1="141" y1="5" x2="141" y2="85" stroke="black" stroke-width="1"/>
<!-- All 10 filled -->
<circle cx="22" cy="25" r="12" fill="#86efac"/>
<circle cx="56" cy="25" r="12" fill="#86efac"/>
<circle cx="90" cy="25" r="12" fill="#86efac"/>
<circle cx="124" cy="25" r="12" fill="#86efac"/>
<circle cx="158" cy="25" r="12" fill="#86efac"/>
<circle cx="22" cy="65" r="12" fill="#86efac"/>
<circle cx="56" cy="65" r="12" fill="#86efac"/>
<circle cx="90" cy="65" r="12" fill="#86efac"/>
<circle cx="124" cy="65" r="12" fill="#86efac"/>
<circle cx="158" cy="65" r="12" fill="#86efac"/>
<!-- Plus sign -->
<text x="190" y="50" font-family="sans-serif" font-size="24" text-anchor="middle">+</text>
<!-- Second frame (2 remaining) -->
<rect x="205" y="5" width="170" height="80" fill="none" stroke="black" stroke-width="2"/>
<line x1="205" y1="45" x2="375" y2="45" stroke="black" stroke-width="1"/>
<line x1="239" y1="5" x2="239" y2="85" stroke="black" stroke-width="1"/>
<line x1="273" y1="5" x2="273" y2="85" stroke="black" stroke-width="1"/>
<line x1="307" y1="5" x2="307" y2="85" stroke="black" stroke-width="1"/>
<line x1="341" y1="5" x2="341" y2="85" stroke="black" stroke-width="1"/>
<!-- 2 filled -->
<circle cx="222" cy="25" r="12" fill="#93c5fd"/>
<circle cx="256" cy="25" r="12" fill="#93c5fd"/>
<text x="190" y="105" font-family="sans-serif" font-size="14" text-anchor="middle">1 ten (green)</text>
<text x="290" y="105" font-family="sans-serif" font-size="14" text-anchor="middle">+ 2 ones (blue) = 12</text>
</svg>
## Why Ten-Frames Matter for Regrouping
Regrouping in addition—the concept that when you add numbers and get more than 10 in a place value, you "carry" to the next column—is one of the first abstract mathematical concepts children encounter. And it's hard.
Consider the problem **47 + 38**:
- When adding the ones place: 7 + 8 = 15
- That's "1 ten and 5 ones"
- The ten gets carried to the tens place
This is abstract. What does it _mean_ that 15 is "1 ten and 5 ones"? Why does the "1" move to the tens column? For many students, this becomes a mechanical procedure they follow without understanding.
**Ten-frames make this visible.**
When you represent 7 + 8 with ten-frames:
1. You have a ten-frame with 7 filled boxes
2. You have 8 more to add
3. First, 3 boxes fill up the remaining spaces in the ten-frame → **you made a ten!**
4. The remaining 5 boxes overflow into a second ten-frame
5. Result: **1 full ten-frame (= 10) + 5 extra boxes = 15**
The regrouping isn't a mysterious rule anymore—it's a physical consequence of filling up frames.
## How We Use Ten-Frames in Our Worksheet Generator
Our addition worksheet generator integrates ten-frames directly into problem layout to scaffold the regrouping process. Here's how it works:
### Ten-Frames Appear When Regrouping Happens
The worksheets show **stacked ten-frames** below each place value column that needs regrouping:
- **Bottom frame**: Shows the overflow from the current place value (the "extra" ones that make regrouping necessary)
- **Top frame**: Shows where that overflow goes (carried to the next place value)
- **Color-coded**: Place value colors (blue for ones, green for tens, yellow for hundreds) help connect the frames to their respective columns
For example, in **47 + 38**:
- When adding the ones column (7 + 8), a ten-frame appears below the ones column
- The bottom portion shows the 5 extra ones (in blue) that remain after making a ten
- The top portion shows the 1 ten (in green) that gets carried to the tens column
- Students can literally _see_ how the overflow becomes a carry
### Visual Examples
Let's compare the same problem with and without ten-frames to see the difference:
#### With Ten-Frames: Visual Support for Regrouping
![Problem 47 + 38 with ten-frames](/blog/ten-frame-examples/with-ten-frames.svg)
_Ten-frames appear below the ones column, showing how 7 + 8 = 15 breaks down into 1 ten (carried) and 5 ones (remaining). The bottom frame (blue) shows the 5 ones that stay, while the top frame (green) shows the 1 ten that gets carried._
#### Without Ten-Frames: Abstract Representation
![Problem 47 + 38 without ten-frames](/blog/ten-frame-examples/without-ten-frames.svg)
_The same problem without ten-frames requires students to mentally visualize the regrouping process._
Notice how the ten-frames make the invisible visible. In 47 + 38, when adding the ones column:
- Students see 7 + 8 creates enough to fill one complete ten-frame (10) with 5 left over
- The filled frame (green, top) represents the carry to the tens place
- The 5 remaining boxes (blue, bottom) stay in the ones place
- This visual directly maps to writing "1" in the carry box and "5" in the ones answer
## Pedagogical Progression: When to Show Ten-Frames
Like all scaffolding, ten-frames should be **introduced when needed and faded when mastered**. Our worksheet generator supports three levels of ten-frame scaffolding:
### 1. Beginner Level: Learning with Ten-Frames
**Use when**: Introducing regrouping for the first time
![Beginner problem 28 + 15 with ten-frames](/blog/ten-frame-examples/beginner-ten-frames.svg)
_A simpler problem (28 + 15) with ten-frames. Students see 8 + 5 = 13, which requires regrouping. The ten-frame shows this as 1 full ten (carried) plus 3 ones (remaining)._
At this level, ten-frames appear when problems involve regrouping. This helps students:
- Build visual familiarity with the ten-frame representation
- Practice the "make ten" strategy with concrete support
- Develop number sense about what sums greater than 10 look like
- Connect the visual representation to the carry notation
**Key insight**: Start with problems that have single-digit sums needing regrouping (like 8 + 5, 7 + 6, 9 + 4), where the ten-frame pattern is clearest.
### 2. Intermediate Level: Ten-Frames for Multiple Regroups
**Use when**: Students understand basic regrouping but need support for complex problems
![Problem with ten-frames in multiple columns](/blog/ten-frame-examples/ten-frames-both-columns.svg)
_A more complex problem (57 + 68) that requires regrouping in BOTH place values. Ten-frames appear below both the ones column (7 + 8 = 15) and the tens column (5 + 6 + 1 = 12), showing students how each overflow creates a carry._
This is the "smart scaffolding" level. Ten-frames appear only when they're needed—when a column sum exceeds 10. This:
- Reduces visual clutter on simpler problems
- Draws attention to where regrouping is happening
- Lets students practice both with and without visual support
- Shows how regrouping can cascade across multiple place values
**Key insight**: Problems with multiple regroups (like 57 + 68) are where ten-frames really shine—students can see the parallel structure of "making tens" in different place values.
### 3. Never Show Ten-Frames (Advanced Level)
**Use when**: Students have internalized the regrouping concept
At advanced levels, ten-frames are removed entirely. Students should have developed mental models for regrouping and can work abstractly with just carry boxes and place value colors (which also fade over time).
## The "Make Ten" Strategy in Action
Ten-frames teach more than just regrouping—they teach a fundamental mental math strategy called **"make ten."** Here's how a child thinks through 7 + 8 using ten-frames:
### Step 1: "I have 7"
<svg width="180" height="110" viewBox="0 0 180 110" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="5" width="170" height="80" fill="none" stroke="black" stroke-width="2"/>
<line x1="5" y1="45" x2="175" y2="45" stroke="black" stroke-width="1"/>
<line x1="39" y1="5" x2="39" y2="85" stroke="black" stroke-width="1"/>
<line x1="73" y1="5" x2="73" y2="85" stroke="black" stroke-width="1"/>
<line x1="107" y1="5" x2="107" y2="85" stroke="black" stroke-width="1"/>
<line x1="141" y1="5" x2="141" y2="85" stroke="black" stroke-width="1"/>
<circle cx="22" cy="25" r="12" fill="#93c5fd"/>
<circle cx="56" cy="25" r="12" fill="#93c5fd"/>
<circle cx="90" cy="25" r="12" fill="#93c5fd"/>
<circle cx="124" cy="25" r="12" fill="#93c5fd"/>
<circle cx="158" cy="25" r="12" fill="#93c5fd"/>
<circle cx="22" cy="65" r="12" fill="#93c5fd"/>
<circle cx="56" cy="65" r="12" fill="#93c5fd"/>
<text x="90" y="105" font-family="sans-serif" font-size="14" text-anchor="middle">"I have 7... I need 3 more to fill the frame!"</text>
</svg>
_Child sees: The top row is full (5), bottom row has 2. Three empty boxes remain to make a complete ten._
### Step 2: "I can take 3 from the 8 to fill my frame"
<svg width="380" height="140" viewBox="0 0 380 140" xmlns="http://www.w3.org/2000/svg">
<!-- First frame (7) -->
<rect x="5" y="5" width="170" height="80" fill="none" stroke="black" stroke-width="2"/>
<line x1="5" y1="45" x2="175" y2="45" stroke="black" stroke-width="1"/>
<line x1="39" y1="5" x2="39" y2="85" stroke="black" stroke-width="1"/>
<line x1="73" y1="5" x2="73" y2="85" stroke="black" stroke-width="1"/>
<line x1="107" y1="5" x2="107" y2="85" stroke="black" stroke-width="1"/>
<line x1="141" y1="5" x2="141" y2="85" stroke="black" stroke-width="1"/>
<circle cx="22" cy="25" r="12" fill="#93c5fd"/>
<circle cx="56" cy="25" r="12" fill="#93c5fd"/>
<circle cx="90" cy="25" r="12" fill="#93c5fd"/>
<circle cx="124" cy="25" r="12" fill="#93c5fd"/>
<circle cx="158" cy="25" r="12" fill="#93c5fd"/>
<circle cx="22" cy="65" r="12" fill="#93c5fd"/>
<circle cx="56" cy="65" r="12" fill="#93c5fd"/>
<!-- The 3 that will move -->
<circle cx="90" cy="65" r="12" fill="#fbbf24" stroke="#000" stroke-width="2" stroke-dasharray="4"/>
<circle cx="124" cy="65" r="12" fill="#fbbf24" stroke="#000" stroke-width="2" stroke-dasharray="4"/>
<circle cx="158" cy="65" r="12" fill="#fbbf24" stroke="#000" stroke-width="2" stroke-dasharray="4"/>
<!-- Arrow -->
<path d="M 90 95 Q 130 110 170 95" fill="none" stroke="#000" stroke-width="2" marker-end="url(#arrowhead)"/>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<polygon points="0 0, 10 3, 0 6" fill="#000"/>
</marker>
</defs>
<text x="130" y="130" font-family="sans-serif" font-size="12" text-anchor="middle">Take 3 from 8</text>
<!-- Second frame (remaining 5) -->
<rect x="205" y="5" width="170" height="80" fill="none" stroke="black" stroke-width="2"/>
<line x1="205" y1="45" x2="375" y2="45" stroke="black" stroke-width="1"/>
<line x1="239" y1="5" x2="239" y2="85" stroke="black" stroke-width="1"/>
<line x1="273" y1="5" x2="273" y2="85" stroke="black" stroke-width="1"/>
<line x1="307" y1="5" x2="307" y2="85" stroke="black" stroke-width="1"/>
<line x1="341" y1="5" x2="341" y2="85" stroke="black" stroke-width="1"/>
<circle cx="222" cy="25" r="12" fill="#93c5fd"/>
<circle cx="256" cy="25" r="12" fill="#93c5fd"/>
<circle cx="290" cy="25" r="12" fill="#93c5fd"/>
<circle cx="324" cy="25" r="12" fill="#93c5fd"/>
<circle cx="358" cy="25" r="12" fill="#93c5fd"/>
<text x="290" y="105" font-family="sans-serif" font-size="14" text-anchor="middle">5 left over</text>
</svg>
_Child thinks: "8 is really 3 and 5. I'll use the 3 to complete my ten-frame, and I have 5 extras."_
### Step 3: "Now I have 10 + 5 = 15!"
<svg width="380" height="110" viewBox="0 0 380 110" xmlns="http://www.w3.org/2000/svg">
<!-- Full frame (10) -->
<rect x="5" y="5" width="170" height="80" fill="none" stroke="black" stroke-width="2"/>
<line x1="5" y1="45" x2="175" y2="45" stroke="black" stroke-width="1"/>
<line x1="39" y1="5" x2="39" y2="85" stroke="black" stroke-width="1"/>
<line x1="73" y1="5" x2="73" y2="85" stroke="black" stroke-width="1"/>
<line x1="107" y1="5" x2="107" y2="85" stroke="black" stroke-width="1"/>
<line x1="141" y1="5" x2="141" y2="85" stroke="black" stroke-width="1"/>
<circle cx="22" cy="25" r="12" fill="#86efac"/>
<circle cx="56" cy="25" r="12" fill="#86efac"/>
<circle cx="90" cy="25" r="12" fill="#86efac"/>
<circle cx="124" cy="25" r="12" fill="#86efac"/>
<circle cx="158" cy="25" r="12" fill="#86efac"/>
<circle cx="22" cy="65" r="12" fill="#86efac"/>
<circle cx="56" cy="65" r="12" fill="#86efac"/>
<circle cx="90" cy="65" r="12" fill="#86efac"/>
<circle cx="124" cy="65" r="12" fill="#86efac"/>
<circle cx="158" cy="65" r="12" fill="#86efac"/>
<text x="190" y="50" font-family="sans-serif" font-size="24" text-anchor="middle">+</text>
<!-- Partial frame (5) -->
<rect x="205" y="5" width="170" height="80" fill="none" stroke="black" stroke-width="2"/>
<line x1="205" y1="45" x2="375" y2="45" stroke="black" stroke-width="1"/>
<line x1="239" y1="5" x2="239" y2="85" stroke="black" stroke-width="1"/>
<line x1="273" y1="5" x2="273" y2="85" stroke="black" stroke-width="1"/>
<line x1="307" y1="5" x2="307" y2="85" stroke="black" stroke-width="1"/>
<line x1="341" y1="5" x2="341" y2="85" stroke="black" stroke-width="1"/>
<circle cx="222" cy="25" r="12" fill="#93c5fd"/>
<circle cx="256" cy="25" r="12" fill="#93c5fd"/>
<circle cx="290" cy="25" r="12" fill="#93c5fd"/>
<circle cx="324" cy="25" r="12" fill="#93c5fd"/>
<circle cx="358" cy="25" r="12" fill="#93c5fd"/>
<text x="190" y="105" font-family="sans-serif" font-size="14" text-anchor="middle" font-weight="bold">10</text>
<text x="290" y="105" font-family="sans-serif" font-size="14" text-anchor="middle" font-weight="bold">+ 5 = 15</text>
</svg>
_Child concludes: "One complete frame is 10, plus 5 more makes 15. So 7 + 8 = 15!"_
This strategy becomes automatic through ten-frame practice. Eventually, students can mentally visualize the frames without seeing them, dramatically improving addition fluency.
## Research Foundations
The use of ten-frames for teaching addition aligns with established learning science:
**Concrete-Representational-Abstract (CRA) Sequence**: Students learn best by progressing from concrete manipulatives → visual representations → abstract symbols. Ten-frames serve as the representational bridge between counting physical objects and working with pure numbers.
**Making Thinking Visible**: Regrouping is an abstract mental process. Ten-frames externalize this process, allowing teachers to see what students understand and where they struggle.
**Cognitive Load Theory**: Visual scaffolds like ten-frames reduce extraneous cognitive load (figuring out how to represent the problem) so students can focus on germane load (understanding the mathematical relationships).
## When to Fade Ten-Frames
The goal isn't to use ten-frames forever—it's to use them as a bridge to abstract understanding. Signs that students are ready to move beyond ten-frames:
1. **Automatic recognition**: They can instantly recognize ten-frame patterns without counting
2. **Mental visualization**: They describe using ten-frames even when not shown ("I made a ten with the 7 and 3")
3. **Fluent regrouping**: They correctly regroup without visual support on most problems
4. **Preference for speed**: They start viewing ten-frames as "extra work" rather than helpful
Our scaffold fading system automates this progression: use the "Less support" difficulty adjustment to reduce ten-frames from "always" → "when regrouping" → "never" as students demonstrate mastery.
## Try It Yourself
Our worksheet generator at **[abaci.one/create/worksheets/addition](https://abaci.one/create/worksheets/addition)** gives you complete control over ten-frame scaffolding:
**For early learners**:
- Set difficulty to "Beginner" or "Early Learner"
- Ten-frames will appear when problems involve regrouping
- Problems start simple to build confidence
**To practice the "make ten" strategy**:
- Use "More support" to set ten-frames to "always"
- Generate problems with moderate regrouping (pAnyStart = 0.5-0.7)
- Students see ten-frames on every problem to build pattern recognition
**To fade scaffolding gradually**:
- Start at "Early Learner" (ten-frames when regrouping)
- Use "Less support" to reduce other scaffolds first (carry boxes, colors)
- Finally use "Less support" again to remove ten-frames
- Students transition smoothly from concrete to abstract
## The Bigger Picture: Adaptive Scaffolding
Ten-frames are one component of our larger scaffolding system. The power comes from **adaptive, conditional scaffolding** that appears exactly when needed:
- Ten-frames when regrouping
- Carry boxes when carrying
- Place value colors for larger numbers
- Answer boxes for alignment practice
This creates worksheets that provide just enough support for each problem's complexity, following Vygotsky's Zone of Proximal Development: challenging enough to promote learning, supported enough to prevent frustration.
## What's Next
We're exploring extensions of the ten-frame approach to:
- **Subtraction with borrowing**: Showing how taking away requires "breaking" a ten
- **Decimal addition**: Using ten-frames to show regrouping across the decimal point
- **Fraction concepts**: Visual representation of part-whole relationships
The code for our ten-frame implementation is **open source**: [github.com/antialias/soroban-abacus-flashcards](https://github.com/antialias/soroban-abacus-flashcards)
See the technical details in our [typstHelpers.ts](https://github.com/antialias/soroban-abacus-flashcards/blob/main/apps/web/src/app/create/worksheets/addition/typstHelpers.ts) file, which generates the ten-frame visualizations.
## Feedback Welcome
We'd love to hear from teachers using ten-frames:
- Are the stacked frames (showing both the overflow and the carry) helpful or confusing?
- Should we add configuration for single vs. double ten-frames?
- What other visual representations would support regrouping?
Share your thoughts via [GitHub issues](https://github.com/antialias/soroban-abacus-flashcards/issues) or try the worksheet generator and let us know how it works with your students.
---
_The ten-frame scaffolding system described here is part of our 2D difficulty research. Students progress through a pedagogically-constrained space where problem complexity and instructional support balance appropriately for each learner. Read more in our [2D difficulty post](/blog/beyond-easy-and-hard)._

View File

@@ -1,45 +0,0 @@
---
title: "The Fluxion of Fortune"
description: "for Isaac, who mastered the Mint but not the market"
author: "Abaci.one Team"
publishedAt: "2025-12-07"
updatedAt: "2025-12-07"
tags: ["poetry", "history", "mathematics", "newton", "calculus"]
featured: false
---
![Newton contemplating his losses](/blog/newton-fluxional-lament.jpeg)
**Pronunciation Guide:** ẋ is "ex dot" · <span style="position: relative;">x<span style="position: absolute; top: -0.25em; left: 0; right: 0; text-align: center;">̄</span></span> is "ex bar"
---
In Woolsthorpe nights of candle-glow,
He watched the apple fall just so—
A whisper of ẋ murmured low,
A hint of what he'd one day know.
He carved the world in curves of light,
Bent stars with math he birthed outright;
But markets?—aye, a different fight,
Whose tides he failed to fluxion right.
For coin obeys no cosmic plot,
It drifts where fevered humors rot;
He'd trace a comet's path on the spot—
Yet never guessed his stocks were naught.
He could have tamed each rising bar,
Mapped fortunes by <span style="position: relative;">x<span style="position: absolute; top: -0.25em; left: 0; right: 0; text-align: center;">̄</span></span> afar,
And steered his purse like some bright star—
Not wrecked upon a South Sea scar.
But genius trips where mortals trot,
And even Newton, spared no blot,
Might've saved his gold—had he used ẋ
To see the crash begot.
And thus his wealth slipped through the sieve,
With no derivative to give;
He mastered laws by which stars live,
But found the Market... negative.

View File

@@ -1,330 +0,0 @@
---
title: "The Calculator Won: Why the Abacus Never Reached American Schools"
description: "In Japan, every third-grader learns soroban. In the US, we relegated it to preschool counting toys. Here's what happened—and what we're missing."
author: "Abaci.one Team"
publishedAt: "2025-11-07"
updatedAt: "2025-11-07"
tags: ["education", "history", "soroban", "pedagogy", "mental-math"]
featured: true
---
# The Calculator Won: Why the Abacus Never Reached American Schools
Every Japanese third-grader spends part of their school day manipulating beads on a soroban, the Japanese abacus. They learn to add, subtract, multiply, and divide—first with the physical tool, then by visualizing it mentally. By fifth grade, many can perform multi-digit arithmetic faster than most adults can type numbers into a calculator.
In American schools, the abacus sits in preschool classrooms as a counting toy. We teach kindergarteners to slide colorful beads along wires to learn "1, 2, 3, 4, 5," and then we move on. By third grade, the abacus is gone, replaced by pencil-and-paper algorithms and, increasingly, by calculators.
This divergence wasn't inevitable. The abacus had a foothold in 19th-century American classrooms. But by the mid-20th century, a combination of pedagogical shifts, technological change, and cultural assumptions pushed it to the margins. Today, as a handful of programs attempt to reintroduce soroban training to U.S. students, we're left wondering: what might have been different?
## A Brief, Unremarkable History: The Abacus in America
The counting frame—a horizontal bead rack similar to the Russian _schoty_—arrived in American classrooms in the 1820s, imported from Europe by educational reformers inspired by Pestalozzi's hands-on teaching methods. Throughout the 19th century, it was a common sight: teachers used bead frames to help children visualize numbers, understand place value, and learn basic arithmetic.
But it never became more than a beginner's tool. American math education was moving toward abstract written methods—Hindu-Arabic numerals, pencil-and-paper algorithms, memorization of math facts. The abacus served as training wheels for counting and simple addition, but teachers didn't develop sophisticated calculation techniques with it. By the early 20th century, progressive educators still supported manipulatives for young learners, but the abacus was just one tool among many, and not a particularly special one.
When electronic calculators became affordable in the 1970s, the abacus's fate was sealed. Why teach students to manipulate beads when they could press buttons? The calculator was faster, more accurate, and represented modernity. The abacus, by contrast, seemed antiquated—a relic from a pre-computational age.
**The result:** By the 1980s, the abacus had virtually disappeared from American public school curricula. It survived only in two narrow contexts:
1. **Early childhood education**: As a simple counting manipulative for ages 4-6
2. **Special education for the blind**: The Cranmer abacus remains essential for visually impaired students who can't use pencil and paper
In mainstream elementary education, from first grade onward, the abacus was effectively extinct.
## Meanwhile, in Japan: A Different Path
Japan's experience with the soroban followed an entirely different trajectory. Introduced from China in the 15th century, the soroban was refined over centuries into a sophisticated calculation tool. By the late 19th century, Japanese mathematicians had standardized techniques for addition, subtraction, multiplication, division—even square roots.
In **1938**, Japan's Education Ministry made a decisive move: they formally included soroban operation techniques in the national elementary arithmetic curriculum. This wasn't a suggestion; it was mandated instruction. Every Japanese child would learn to calculate on the abacus.
Even as Japan modernized rapidly—embracing computers, electronics, and cutting-edge technology—the soroban remained in classrooms. When debates arose in the post-war period about whether it was still relevant in the calculator age, the government doubled down. In **1989**, far from phasing out the soroban, the Ministry of Education _expanded_ instruction to include both third and fourth grades.
Why? The reasoning wasn't nostalgia. It was pedagogical:
- **Mental calculation skills**: Soroban training develops anzan—the ability to visualize an abacus mentally and perform calculations without a physical tool
- **Number sense**: Constant manipulation of place values builds deep understanding of how numbers work
- **Cognitive benefits**: Research suggests soroban training enhances working memory, concentration, and numerical processing
- **Cultural continuity**: Soroban proficiency is a mark of academic discipline and mathematical skill
Today, Japanese elementary students still receive systematic soroban instruction. After-school soroban academies (_juku_) operate across the country, where children practice for certification exams that millions have taken since 1928. Elite soroban users can add 10-digit numbers in seconds and perform complex mental arithmetic that looks like magic to untrained observers.
## The Cost of the Calculator: What We Gave Up
American education made a bet: calculators would render manual calculation obsolete, so we could skip the tedious bead-pushing and focus on higher-order mathematical thinking.
**The bet hasn't paid off.**
U.S. students consistently struggle with basic arithmetic fluency. National assessments show that many fourth-graders can't reliably add two-digit numbers with regrouping. Mental math is nearly non-existent—ask an adult to compute 47 + 38 without pencil, paper, or phone, and watch them struggle.
This isn't just about speed. **Weak number sense undermines everything that comes later.** If students don't have an intuitive grasp of how numbers combine, decompose, and relate to each other, they'll struggle with fractions, algebra, and every subsequent mathematical concept.
Calculators haven't freed students to focus on "higher-order thinking." Instead, they've created a generation that can't estimate, can't verify answers for reasonableness, and reaches for a device to compute 15% of $40.
The Japanese approach suggests an alternative. **What if calculation fluency isn't a tedious prerequisite to real math, but rather the foundation that makes advanced thinking possible?** What if the hours spent manipulating beads—making tangible the abstract relationships between numbers—builds cognitive infrastructure that no calculator can replace?
## What Japan Has That We Don't
The difference isn't just about soroban vs. no soroban. It's about fundamentally different assumptions about what elementary math education should accomplish.
### Mental Math as a Core Skill
In Japan, mental calculation is an explicit instructional goal. Students are expected to compute multi-digit problems in their heads by visualizing the soroban. This isn't parlor trick memorization—it's systematic training in holding numerical representations in working memory and manipulating them mentally.
American students, by contrast, are rarely taught mental math as a distinct skill. We emphasize written algorithms and, increasingly, calculator use. Mental math is something clever kids figure out on their own, not something we systematically cultivate.
### Calculation as Understanding, Not Just Procedure
When Japanese students use the soroban, they're not just executing algorithms—they're physically manipulating place values, seeing how carries propagate, feeling the structure of multidigit operations. The tool makes the abstract concrete.
American algorithms (the "carrying" method for addition, long division, etc.) are taught as procedures to memorize. Many students execute them correctly without understanding why they work. The soroban forces understanding because you can't manipulate it correctly without grasping the underlying place value logic.
### Patience for Mastery
The Japanese curriculum assumes that students need years of practice to develop true computational fluency. Soroban training starts in third grade and continues through elementary school. After-school programs extend this to thousands of hours of deliberate practice.
American education is impatient. We introduce concepts quickly, provide limited practice, and move on. "They can use a calculator" becomes the escape hatch when fluency doesn't develop.
## The Transformative Potential: What Could Change
Imagine if American elementary schools adopted soroban training comparable to what Japan mandates. What would change?
### 1. Number Sense as a Foundation
Students who spend hundreds of hours manipulating an abacus develop an intuitive understanding of:
- **Place value**: Each column represents a power of ten, visibly distinct
- **Decomposition**: Numbers break apart and recombine fluidly (47 is "4 tens and 7 ones")
- **Magnitude**: Larger numbers require more columns; the scale is visible
- **Operations**: Addition is physically "adding beads"; subtraction is "removing beads"
This isn't abstract knowledge you memorize for a test. It's embodied understanding that makes every subsequent mathematical concept more comprehensible.
### 2. Mental Calculation That Actually Works
The progression from physical soroban to mental visualization (_anzan_) creates a lasting mental tool. Students learn to:
- Hold visual representations in working memory
- Manipulate those representations systematically
- Compute without external aids at remarkable speed
This isn't about competing with calculators—it's about having a reliable internal verification system. Can the student tell if the calculator's answer makes sense? Can they estimate before computing? With mental math fluency, yes.
### 3. Cognitive Benefits Beyond Math
Research on abacus-trained students (primarily from Asia) suggests broader cognitive gains:
- **Working memory**: Holding and manipulating visual-spatial information improves general working memory capacity
- **Concentration**: Hours of focused bead manipulation builds sustained attention
- **Confidence**: Mastery of calculation builds mathematical self-efficacy
One neuroscience review found that abacus-based mental calculation training enhances mathematics ability, working memory, and numerical processing, with measurable changes in brain regions linked to memory and reasoning.
### 4. Equity and Accessibility
The soroban is fundamentally democratic:
- **Low cost**: A durable wooden abacus costs $10-20 and lasts decades
- **No prerequisites**: Any child can start with basic counting
- **Immediate feedback**: You can see if your answer is right by reading the beads
- **Scalable**: One teacher can guide 30 students practicing individually
Compare this to educational software (requires devices, internet, maintenance) or intensive tutoring (expensive, doesn't scale). The soroban is cheap, robust, and proven.
## The Reality Check: Where's the "Revival"?
The research document provided claims a "revival" of abacus education in U.S. schools. Let's be honest about what that means.
**The truth: There is no meaningful revival in American public schools.**
Yes, there are efforts:
- **DRANREF Foundation's Abacus Project** ran pilot programs in D.C., reporting that 89% of students improved math fluency over 12 weeks
- **Private programs** like UCMAS, ALOHA, and others operate after-school centers in major cities
- **Some elementary schools** offer abacus as an optional after-school club
But scale matters. Since 2015, the DRANREF Foundation has reached about 2,000 students and 170 teachers—across the entire United States. For context, there are roughly 35 million elementary school students in the U.S.
The "revival" consists of:
- A handful of pilot programs in individual schools
- Private enrichment centers serving families who can afford them (typically $100-200/month)
- Scattered after-school clubs with limited capacity
**This is not a revival. This is boutique experimentation.**
No state has adopted soroban training in their standards. No major school district has made it part of the core curriculum. Teacher preparation programs don't train educators in soroban instruction. The federal government hasn't funded large-scale research or implementation.
The abacus remains, as it has been for 50 years, marginal in American education.
## Why the Revival Isn't Happening (And Won't Soon)
Several structural barriers prevent widespread adoption:
### 1. Curricular Inertia
American math standards (Common Core, state frameworks) don't mention the abacus beyond kindergarten manipulatives. Changing standards is a slow, politically fraught process. Without standards support, textbook publishers won't create soroban materials, and schools won't adopt them.
### 2. Teacher Training
No U.S. teacher preparation program trains elementary educators in soroban instruction. Even if a school wanted to adopt it, where would they find qualified teachers? You can't mandate something the workforce isn't prepared to teach.
### 3. The Calculator Culture
American culture deeply believes that manual calculation is obsolete. "Why waste time on that when they have calculators?" is the dominant view. Convincing educators, administrators, parents, and policymakers that calculation fluency matters requires shifting a decades-old consensus.
### 4. No Compelling Crisis
Until student math performance becomes a genuine crisis that demands novel solutions, inertia will win. The current approach is "good enough"—we can point to some students who succeed, blame failures on external factors, and continue business as usual.
### 5. Equity Optics
Ironically, the abacus can be dismissed as culturally foreign ("That's an Asian thing") or antiquated ("We're not going back to the 1800s"). Any proposal to adopt techniques from other countries faces suspicion and resistance.
## What It Would Actually Take
If the U.S. wanted to seriously integrate soroban training into elementary math education, here's what would be required:
### Phase 1: Research and Pilot Programs (5 years)
- **Randomized controlled trials** comparing soroban-trained students to matched controls on standardized math assessments
- **Longitudinal studies** tracking cognitive development and long-term outcomes
- **Implementation research** documenting teacher training requirements, student engagement, and cost-effectiveness
- **Curriculum development** aligning soroban instruction with Common Core and state standards
**Estimated cost**: $50-100 million for rigorous research across multiple sites
### Phase 2: Teacher Preparation (10 years)
- **Pre-service training**: Integrate soroban instruction into elementary education programs at universities
- **In-service professional development**: Train current teachers through workshops, coaching, and certification programs
- **Master teacher networks**: Create cohorts of soroban specialists who can mentor peers
**Estimated cost**: $500 million - $1 billion for national-scale training infrastructure
### Phase 3: Curriculum Integration (10 years)
- **Standards revision**: Update state math standards to explicitly include soroban/mental math outcomes
- **Materials development**: Create textbooks, digital tools, and assessment instruments
- **Implementation support**: Provide ongoing coaching and resources to schools
**Estimated cost**: Billions of dollars (standards change is politically expensive; materials development is ongoing)
### Total Timeline: 20-25 years from serious commitment to widespread practice
**This is not happening.** There is no political will, no funding, and no constituency demanding it.
## So What Can We Do?
If a national transformation isn't coming, individual educators and parents still have options.
### For Teachers
- **Experiment in your classroom**: Use soroban as a manipulative for place value and mental math, even if it's not in your curriculum
- **Start an after-school club**: Offer abacus as enrichment for interested students
- **Share results**: Document student progress and share with colleagues to build interest
### For Parents
- **Enroll in private programs**: UCMAS, ALOHA, and similar centers exist in many cities
- **Learn together at home**: Purchase a soroban ($10-20) and use online tutorials (YouTube has many excellent series)
- **Advocate for school adoption**: Ask your school's math department to consider pilot programs
### For Education Leaders
- **Fund pilot studies**: Small-scale experiments in a few schools can generate local evidence
- **Partner with researchers**: University education departments may be interested in studying implementation
- **Connect with existing programs**: The DRANREF Foundation and other nonprofits offer training and materials
### For All of Us: Use Tools Like This One
The website you're on right now—**Abaci.one**—exists because mainstream education isn't meeting this need. We're building open-source tools to make soroban training accessible:
- **Interactive tutorials** for learning to read and manipulate the soroban
- **Practice games** that build mental calculation fluency
- **Printable flashcards** for deliberate practice
- **Research-based pedagogy** grounded in learning science
You don't need to wait for your school district. You don't need to pay $200/month for classes. You can start today, for free, right here.
## The Bigger Picture: What the Abacus Represents
This isn't really about beads and wires. The abacus is a proxy for a deeper question: **What do we believe about how children learn mathematics?**
The American approach assumes:
- Abstract symbolic manipulation is sufficient
- Calculators make manual calculation obsolete
- Speed matters more than understanding
- Innovation means new technology, not old tools refined
The Japanese approach (and that of many Asian countries) assumes:
- Embodied, physical practice builds durable understanding
- Mental calculation develops cognitive skills beyond computation
- Mastery requires thousands of hours of deliberate practice
- Old tools, deeply understood, can be more powerful than new ones superficially adopted
**We chose calculators. They chose soroban. The results speak for themselves.**
International assessments consistently show Japanese students outperforming American peers in mathematical problem-solving, number sense, and computational fluency. This isn't solely because of the soroban—Japan invests heavily in math education overall—but it's a piece of the puzzle we've ignored.
## A Modest Proposal
We're not going to transform American math education overnight. But we could start with something achievable:
**Make mental math a valued, explicitly taught skill in elementary school.**
It doesn't have to be soroban (though that would be great). It could be:
- **Vedic mathematics** (Indian mental math techniques)
- **Number talks** (structured classroom discussions about mental strategies)
- **Estimation routines** (daily practice with reasonable approximation)
- **Rekenrek** (the Dutch arithmetic rack, already used in some U.S. classrooms)
The specific tool matters less than the commitment: **We teach students to compute mentally, systematically, with sufficient practice to develop real fluency.**
If we made this commitment, the soroban would be an obvious candidate for inclusion. It's proven, it's cheap, it's scalable, and it works. But even if we never adopt it, we could learn from the principle it represents: **computational fluency isn't the enemy of conceptual understanding. It's the foundation.**
## Try It Yourself
Don't take my word for it. Spend 20 minutes learning to add two-digit numbers on a soroban. You'll experience:
- How visible place value becomes when each column is physically distinct
- How regrouping (carrying) makes tangible sense when you physically move beads
- How mental visualization naturally develops from physical practice
- Why students trained this way develop both speed and understanding
We've built an interactive tutorial right here at [abaci.one/tutorial](/tutorial). It's free, it's self-paced, and it will convince you—more than any essay could—that there's something powerful here we've been missing.
## The Uncomfortable Truth
The abacus never reached American schools because **we decided it wasn't worth the effort.**
We had it. We used it. And we consciously chose to set it aside when calculators appeared. That choice made sense in the moment—why cling to old tools when new ones are available?
But Japan made a different choice. They kept the soroban, integrated it into their curriculum, and systematically developed their students' mental calculation abilities to levels we can barely imagine.
**Fifty years later, the results are in. They were right. We were wrong.**
The question now is whether we have the humility to admit it, and the will to do something about it.
Probably not. American education is extremely resistant to learning from other countries, especially when it means acknowledging that older methods might be superior to our "modern" approaches.
But maybe, at the margins, some teachers will try. Some parents will enroll their kids. Some students will discover that moving beads on wires unlocks mathematical understanding in a way abstract algorithms never did.
And maybe, in 20 years, we'll have enough of these stories that someone will finally ask: **Why isn't everyone doing this?**
That would be a real revival.
---
## Further Reading
- **Historical context**: Pat Ballew's [blog on the abacus in American education](http://pballew.blogspot.com/) documents its early use and decline
- **Japanese system**: The [League of Japan Abacus Associations](https://www.shuzan.jp/english/) provides history and current practices
- **Cognitive research**: Wang et al. (2020), "Abacus Training Enhances Neural Correlates of Phonological Processing," _Frontiers in Neuroscience_
- **Pilot programs**: [The Abacus Project by DRANREF Foundation](https://www.tdfgives.org/)
---
_This post reflects our conviction at Abaci.one that accessible, evidence-based tools can democratize mathematical fluency. The soroban won't solve every problem in math education, but it's a proven approach we've foolishly ignored. We're building digital tools to make it accessible to everyone—because no student should be denied effective instruction just because their school made the wrong choice 50 years ago._

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

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