chore: cleanup unused imports and apply formatting

- Remove unused `and` import from VisionRecorder.ts
- Remove unused `IncomingMessage` and `ws` imports from socket-server.ts
- Add `muted` attribute to video element in ProblemVideoPlayer
- Apply code formatting across vision and practice components
- Update documentation formatting in DEPLOYMENT.md and README

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-14 18:49:31 -06:00
parent cecd1e93e2
commit e703e90875
16 changed files with 164 additions and 68 deletions

View File

@ -52,6 +52,7 @@ curl https://abaci.one/api/health
``` ```
Response: Response:
```json ```json
{ {
"status": "healthy", "status": "healthy",
@ -173,11 +174,13 @@ ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-c
### Health Check Failing ### Health Check Failing
1. Check container logs: 1. Check container logs:
```bash ```bash
ssh nas.home.network "docker logs abaci-blue" ssh nas.home.network "docker logs abaci-blue"
``` ```
2. Test health endpoint manually: 2. Test health endpoint manually:
```bash ```bash
ssh nas.home.network "docker exec abaci-blue curl -sf http://localhost:3000/api/health" ssh nas.home.network "docker exec abaci-blue curl -sf http://localhost:3000/api/health"
``` ```
@ -209,6 +212,7 @@ If upgrading from the old single-container setup:
``` ```
This script will: This script will:
1. Stop the old `soroban-abacus-flashcards` container 1. Stop the old `soroban-abacus-flashcards` container
2. Stop compose-updater temporarily 2. Stop compose-updater temporarily
3. Deploy the new docker-compose.yaml 3. Deploy the new docker-compose.yaml

View File

@ -37,6 +37,7 @@ curl https://abaci.one/api/health
``` ```
Response: Response:
```json ```json
{ {
"status": "healthy", "status": "healthy",
@ -174,11 +175,13 @@ git rev-parse HEAD
### Health Check Failing ### Health Check Failing
1. Check container logs: 1. Check container logs:
```bash ```bash
ssh nas.home.network "docker logs abaci-blue" ssh nas.home.network "docker logs abaci-blue"
``` ```
2. Test health endpoint manually: 2. Test health endpoint manually:
```bash ```bash
ssh nas.home.network "docker exec abaci-blue curl -sf http://localhost:3000/api/health" ssh nas.home.network "docker exec abaci-blue curl -sf http://localhost:3000/api/health"
``` ```

View File

@ -152,7 +152,9 @@ export async function GET(request: Request, { params }: RouteParams) {
if (video.status === 'no_video') { if (video.status === 'no_video') {
return NextResponse.json( return NextResponse.json(
{ error: 'No video was recorded for this problem (camera may have been off)' }, {
error: 'No video was recorded for this problem (camera may have been off)',
},
{ status: 404 } { status: 404 }
) )
} }

View File

@ -582,7 +582,10 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
// Handle part transition complete - called when transition screen finishes // Handle part transition complete - called when transition screen finishes
// This is where we trigger game break (after "put away abacus" message is shown) // This is where we trigger game break (after "put away abacus" message is shown)
const handlePartTransitionComplete = useCallback(() => { const handlePartTransitionComplete = useCallback(() => {
console.log('[PracticeClient] handlePartTransitionComplete called, pendingGameBreak:', pendingGameBreak) console.log(
'[PracticeClient] handlePartTransitionComplete called, pendingGameBreak:',
pendingGameBreak
)
// First, broadcast to observers // First, broadcast to observers
sendPartTransitionComplete() sendPartTransitionComplete()

View File

@ -247,7 +247,13 @@ export function BroadcastDebugPanel({
border: '1px solid rgba(96, 165, 250, 0.3)', border: '1px solid rgba(96, 165, 250, 0.3)',
})} })}
> >
<div className={css({ color: '#60a5fa', fontWeight: 'bold', marginBottom: '6px' })}> <div
className={css({
color: '#60a5fa',
fontWeight: 'bold',
marginBottom: '6px',
})}
>
Current State Current State
</div> </div>
<div className={css({ color: '#d1d5db', fontSize: '10px' })}> <div className={css({ color: '#d1d5db', fontSize: '10px' })}>
@ -275,7 +281,13 @@ export function BroadcastDebugPanel({
)} )}
{lastBroadcastTime && ( {lastBroadcastTime && (
<div className={css({ color: '#6b7280', fontSize: '10px', marginBottom: '8px' })}> <div
className={css({
color: '#6b7280',
fontSize: '10px',
marginBottom: '8px',
})}
>
Last broadcast: {new Date(lastBroadcastTime).toLocaleTimeString()} Last broadcast: {new Date(lastBroadcastTime).toLocaleTimeString()}
</div> </div>
)} )}

View File

@ -283,7 +283,13 @@ export function ObserverDebugPanel({
border: '1px solid rgba(244, 114, 182, 0.3)', border: '1px solid rgba(244, 114, 182, 0.3)',
})} })}
> >
<div className={css({ color: '#f472b6', fontWeight: 'bold', marginBottom: '6px' })}> <div
className={css({
color: '#f472b6',
fontWeight: 'bold',
marginBottom: '6px',
})}
>
Observed State Observed State
</div> </div>
<div className={css({ color: '#d1d5db', fontSize: '10px' })}> <div className={css({ color: '#d1d5db', fontSize: '10px' })}>
@ -325,7 +331,13 @@ export function ObserverDebugPanel({
border: '1px solid rgba(96, 165, 250, 0.3)', border: '1px solid rgba(96, 165, 250, 0.3)',
})} })}
> >
<div className={css({ color: '#60a5fa', fontWeight: 'bold', marginBottom: '6px' })}> <div
className={css({
color: '#60a5fa',
fontWeight: 'bold',
marginBottom: '6px',
})}
>
Vision Frame Vision Frame
</div> </div>
<div className={css({ color: '#d1d5db', fontSize: '10px' })}> <div className={css({ color: '#d1d5db', fontSize: '10px' })}>
@ -349,7 +361,13 @@ export function ObserverDebugPanel({
border: '1px solid rgba(168, 85, 247, 0.3)', border: '1px solid rgba(168, 85, 247, 0.3)',
})} })}
> >
<div className={css({ color: '#a855f7', fontWeight: 'bold', marginBottom: '6px' })}> <div
className={css({
color: '#a855f7',
fontWeight: 'bold',
marginBottom: '6px',
})}
>
DVR Buffer DVR Buffer
</div> </div>
<div className={css({ color: '#d1d5db', fontSize: '10px' })}> <div className={css({ color: '#d1d5db', fontSize: '10px' })}>

View File

@ -680,7 +680,10 @@ export function useInteractionPhase(
// If no active problem, nothing to do // If no active problem, nothing to do
if (!activeProblem) { if (!activeProblem) {
console.log('[useInteractionPhase] No active problem - cannot load. Current phase:', phase.phase) console.log(
'[useInteractionPhase] No active problem - cannot load. Current phase:',
phase.phase
)
prevActiveProblemKeyRef.current = null prevActiveProblemKeyRef.current = null
return return
} }
@ -718,7 +721,10 @@ export function useInteractionPhase(
// Case 2: Key changed - handle redo mode or session advancement // Case 2: Key changed - handle redo mode or session advancement
if (keyChanged) { if (keyChanged) {
console.log('[useInteractionPhase] Key changed:', { prevKey, currentKey }) console.log('[useInteractionPhase] Key changed:', {
prevKey,
currentKey,
})
// CRITICAL: Don't interrupt normal progression flow // CRITICAL: Don't interrupt normal progression flow
// If we're in showingFeedback, submitting, or transitioning, the normal flow // If we're in showingFeedback, submitting, or transitioning, the normal flow

View File

@ -469,6 +469,7 @@ export function ProblemVideoPlayer({
src={videoUrl} src={videoUrl}
controls controls
playsInline playsInline
muted
onCanPlay={handleCanPlay} onCanPlay={handleCanPlay}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
className={css({ className={css({
@ -839,8 +840,13 @@ export function ProblemVideoPlayer({
Step {noVideoPlaybackIndex + 1} of {answerProgression.length} Step {noVideoPlaybackIndex + 1} of {answerProgression.length}
</span> </span>
{currentNoVideoState?.originalTimestamp !== undefined && ( {currentNoVideoState?.originalTimestamp !== undefined && (
<span className={css({ color: isDark ? 'gray.600' : 'gray.300' })}> <span
(actual: {(currentNoVideoState.originalTimestamp / 1000).toFixed(1)}s) className={css({
color: isDark ? 'gray.600' : 'gray.300',
})}
>
(actual: {(currentNoVideoState.originalTimestamp / 1000).toFixed(1)}
s)
</span> </span>
)} )}
</div> </div>

View File

@ -325,7 +325,9 @@ describe('useUserPlayers hooks', () => {
}) })
// Should have invalidated with playerKeys.all // Should have invalidated with playerKeys.all
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: playerKeys.all }) expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: playerKeys.all,
})
}) })
test('invalidates all player queries even on error', async () => { test('invalidates all player queries even on error', async () => {
@ -349,7 +351,9 @@ describe('useUserPlayers hooks', () => {
}) })
// onSettled runs on both success and error // onSettled runs on both success and error
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: playerKeys.all }) expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: playerKeys.all,
})
}) })
}) })
@ -422,7 +426,10 @@ describe('useUserPlayers hooks', () => {
const { result } = renderHook(() => useUpdatePlayer(), { wrapper }) const { result } = renderHook(() => useUpdatePlayer(), { wrapper })
act(() => { act(() => {
result.current.mutate({ id: 'player-1', updates: { name: 'Updated Name' } }) result.current.mutate({
id: 'player-1',
updates: { name: 'Updated Name' },
})
}) })
// Wait for optimistic update // Wait for optimistic update
@ -461,7 +468,10 @@ describe('useUserPlayers hooks', () => {
const { result } = renderHook(() => useUpdatePlayer(), { wrapper }) const { result } = renderHook(() => useUpdatePlayer(), { wrapper })
act(() => { act(() => {
result.current.mutate({ id: 'player-1', updates: { name: 'Updated Name' } }) result.current.mutate({
id: 'player-1',
updates: { name: 'Updated Name' },
})
}) })
await waitFor(() => { await waitFor(() => {

View File

@ -406,13 +406,12 @@ export function useSessionBroadcast(
return return
} }
// Only send markers if recording is active // Always send markers - server will capture metadata even without video frames
if (!isRecordingRef.current) { // This enables playback of student answers even when camera wasn't enabled
return
}
socketRef.current.emit('vision-problem-marker', { socketRef.current.emit('vision-problem-marker', {
sessionId, sessionId,
playerId, // Include playerId for auto-starting metadata-only sessions
problemNumber, problemNumber,
partIndex, partIndex,
eventType, eventType,
@ -430,7 +429,7 @@ export function useSessionBroadcast(
retryContext, retryContext,
}) })
}, },
[sessionId] [sessionId, playerId]
) )
return { return {

View File

@ -59,7 +59,7 @@ When a student practices with their camera enabled, the system records a separat
Problem markers are socket events that coordinate video recording with problem lifecycle: Problem markers are socket events that coordinate video recording with problem lifecycle:
| Marker Event | When Sent | VisionRecorder Action | | Marker Event | When Sent | VisionRecorder Action |
|--------------|-----------|----------------------| | ------------------ | ----------------------------- | ------------------------------------------ |
| `problem-shown` | New problem appears on screen | Start buffering frames for this problem | | `problem-shown` | New problem appears on screen | Start buffering frames for this problem |
| `answer-submitted` | Student submits their answer | Stop buffering, encode video, save to disk | | `answer-submitted` | Student submits their answer | Stop buffering, encode video, save to disk |
| `feedback-shown` | Feedback displayed | (Reserved for future use) | | `feedback-shown` | Feedback displayed | (Reserved for future use) |
@ -68,27 +68,29 @@ Problem markers are socket events that coordinate video recording with problem l
```typescript ```typescript
interface ProblemMarker { interface ProblemMarker {
sessionId: string sessionId: string;
problemNumber: number // 1-indexed problem number problemNumber: number; // 1-indexed problem number
partIndex: number // Which part of the session partIndex: number; // Which part of the session
eventType: 'problem-shown' | 'answer-submitted' | 'feedback-shown' eventType: "problem-shown" | "answer-submitted" | "feedback-shown";
isCorrect?: boolean // Only for answer-submitted isCorrect?: boolean; // Only for answer-submitted
// Retry context (for multiple attempts at same problem) // Retry context (for multiple attempts at same problem)
epochNumber: number // 0 = initial pass, 1+ = retry epochs epochNumber: number; // 0 = initial pass, 1+ = retry epochs
attemptNumber: number // Which attempt (1, 2, 3...) attemptNumber: number; // Which attempt (1, 2, 3...)
isRetry: boolean // True if in a retry epoch isRetry: boolean; // True if in a retry epoch
isManualRedo: boolean // True if student clicked dot to redo isManualRedo: boolean; // True if student clicked dot to redo
} }
``` ```
### Why Retry Context Matters ### Why Retry Context Matters
Students can attempt the same problem multiple times: Students can attempt the same problem multiple times:
- **Epoch retries**: End-of-part retry rounds for missed problems - **Epoch retries**: End-of-part retry rounds for missed problems
- **Manual redos**: Student clicks a completed problem dot to practice again - **Manual redos**: Student clicks a completed problem dot to practice again
Each attempt gets its own recording. The retry context determines: Each attempt gets its own recording. The retry context determines:
- **Filename**: `problem_001_e0_a1.mp4` (epoch 0, attempt 1) vs `problem_001_e0_a2.mp4` (attempt 2) - **Filename**: `problem_001_e0_a1.mp4` (epoch 0, attempt 1) vs `problem_001_e0_a2.mp4` (attempt 2)
- **Database record**: Separate rows in `vision_problem_videos` with epoch/attempt fields - **Database record**: Separate rows in `vision_problem_videos` with epoch/attempt fields
@ -112,29 +114,29 @@ Each video has a companion `.meta.json` file with time-coded state for synchroni
```typescript ```typescript
interface ProblemMetadata { interface ProblemMetadata {
problem: { problem: {
terms: number[] // e.g., [45, -23, 12] terms: number[]; // e.g., [45, -23, 12]
answer: number // e.g., 34 answer: number; // e.g., 34
} };
entries: Array<{ entries: Array<{
t: number // ms from video start t: number; // ms from video start
detectedValue: number | null // ML-detected abacus value detectedValue: number | null; // ML-detected abacus value
confidence: number // 0-1 detection confidence confidence: number; // 0-1 detection confidence
studentAnswer: string // Current typed answer studentAnswer: string; // Current typed answer
phase: 'problem' | 'feedback' phase: "problem" | "feedback";
isCorrect?: boolean isCorrect?: boolean;
}> }>;
durationMs: number durationMs: number;
frameCount: number frameCount: number;
isCorrect: boolean | null isCorrect: boolean | null;
} }
``` ```
## Key Files ## Key Files
| File | Purpose | | File | Purpose |
|------|---------| | ------------------------------------------------- | ----------------------------------------------- |
| `src/lib/vision/recording/VisionRecorder.ts` | Server-side recording manager | | `src/lib/vision/recording/VisionRecorder.ts` | Server-side recording manager |
| `src/socket-server.ts` | Socket event routing (lines handling vision-*) | | `src/socket-server.ts` | Socket event routing (lines handling vision-\*) |
| `src/hooks/useSessionBroadcast.ts` | Client socket helpers (`sendProblemMarker`) | | `src/hooks/useSessionBroadcast.ts` | Client socket helpers (`sendProblemMarker`) |
| `src/app/practice/[studentId]/PracticeClient.tsx` | Sends markers on problem state changes | | `src/app/practice/[studentId]/PracticeClient.tsx` | Sends markers on problem state changes |
| `src/components/vision/DockedVisionFeed.tsx` | Captures and sends camera frames | | `src/components/vision/DockedVisionFeed.tsx` | Captures and sends camera frames |
@ -143,7 +145,7 @@ interface ProblemMetadata {
## API Routes ## API Routes
| Route | Purpose | | Route | Purpose |
|-------|---------| | ------------------------------------------------------------------ | ------------------------- |
| `GET /api/curriculum/[playerId]/sessions/[sessionId]/videos` | List available recordings | | `GET /api/curriculum/[playerId]/sessions/[sessionId]/videos` | List available recordings |
| `GET /api/.../problems/[problemNumber]/video?epoch=X&attempt=Y` | Stream video file | | `GET /api/.../problems/[problemNumber]/video?epoch=X&attempt=Y` | Stream video file |
| `GET /api/.../problems/[problemNumber]/metadata?epoch=X&attempt=Y` | Get metadata JSON | | `GET /api/.../problems/[problemNumber]/metadata?epoch=X&attempt=Y` | Get metadata JSON |

View File

@ -1,4 +1,4 @@
import { eq, and } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { createId } from '@paralleldrive/cuid2' import { createId } from '@paralleldrive/cuid2'
import { mkdir, writeFile, rm } from 'fs/promises' import { mkdir, writeFile, rm } from 'fs/promises'
import path from 'path' import path from 'path'

View File

@ -1,7 +1,6 @@
import type { Server as HTTPServer, IncomingMessage } from 'http' import type { Server as HTTPServer } from 'http'
import { Server as SocketIOServer } from 'socket.io' import { Server as SocketIOServer } from 'socket.io'
import type { Server as SocketIOServerType } from 'socket.io' import type { Server as SocketIOServerType } from 'socket.io'
import { WebSocketServer, type WebSocket } from 'ws'
import { import {
applyGameMove, applyGameMove,
createArcadeSession, createArcadeSession,
@ -1174,6 +1173,8 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}) })
// Vision Recording: Handle problem marker (triggers encoding on problem transitions) // Vision Recording: Handle problem marker (triggers encoding on problem transitions)
// NOTE: Markers are always processed - if no recording session exists, one is auto-started
// for metadata-only capture (student answers without video)
socket.on( socket.on(
'vision-problem-marker', 'vision-problem-marker',
async ({ async ({
@ -1186,6 +1187,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
attemptNumber, attemptNumber,
isRetry, isRetry,
isManualRedo, isManualRedo,
playerId,
}: { }: {
sessionId: string sessionId: string
problemNumber: number problemNumber: number
@ -1196,8 +1198,19 @@ export function initializeSocketServer(httpServer: HTTPServer) {
attemptNumber?: number attemptNumber?: number
isRetry?: boolean isRetry?: boolean
isManualRedo?: boolean isManualRedo?: boolean
playerId?: string
}) => { }) => {
const recorder = VisionRecorder.getInstance() const recorder = VisionRecorder.getInstance()
// Auto-start a metadata-only session if one doesn't exist
// This allows capturing student answers even when camera isn't enabled
if (!recorder.isRecording(sessionId) && playerId) {
console.log(
`📝 Auto-starting metadata-only recording session for ${sessionId} (no camera)`
)
recorder.startSession(sessionId, playerId)
}
if (recorder.isRecording(sessionId)) { if (recorder.isRecording(sessionId)) {
// This triggers encoding when 'problem-shown' arrives for the next problem // This triggers encoding when 'problem-shown' arrives for the next problem
await recorder.onProblemMarker(sessionId, { await recorder.onProblemMarker(sessionId, {

View File

@ -12,7 +12,13 @@ services:
networks: networks:
- webgateway - webgateway
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"] test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))",
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 3

View File

@ -12,7 +12,13 @@ services:
networks: networks:
- webgateway - webgateway
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"] test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))",
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 3

View File

@ -20,7 +20,13 @@ x-app: &app
networks: networks:
- webgateway - webgateway
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"] test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))",
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 3