fix(vision): fix problem video recording when camera starts mid-session

- Add effect to emit problem-shown marker when recording starts, ensuring
  the current problem gets a video entry even if camera was enabled late
- Improve DockedVisionFeed UX: show session ID, action buttons immediately
  for remote camera connections (not just after 15s timeout)
- Add .prettierignore to prevent prettier from corrupting Panda CSS
  generated styled-system directory
- Document styled-system fix procedure in CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-13 18:56:21 -06:00
parent 371b449d15
commit fbbbf9f50b
4 changed files with 881 additions and 653 deletions

15
.prettierignore Normal file
View File

@ -0,0 +1,15 @@
# Generated files - do not format
**/styled-system/**
**/.next/**
**/dist/**
**/coverage/**
**/node_modules/**
**/storybook-static/**
# Build outputs
*.min.js
*.min.css
# Package manager
pnpm-lock.yaml
package-lock.json

View File

@ -605,6 +605,28 @@ css({
className = "bg-blue-200 border-gray-300 text-brand-600";
```
### Fixing Corrupted styled-system (Panda CSS)
**If the CSS appears broken or styles aren't applying correctly**, the `styled-system/` directory may be corrupted. This can happen if prettier or other formatters modify the generated files.
**Fix:**
```bash
# 1. Delete the corrupted styled-system
rm -rf apps/web/styled-system
# 2. Regenerate it
cd apps/web && pnpm panda codegen
# 3. Clear Next.js cache (if build errors persist)
rm -rf apps/web/.next
# 4. Rebuild
pnpm build
```
**Prevention:** The repo has a `.prettierignore` at the root that excludes `**/styled-system/**`. If this file is missing or incomplete, prettier will corrupt the generated files when running `pnpm format`.
See `.claude/GAME_THEMES.md` for standardized color theme usage in arcade games.
## Data Attributes for All Elements

View File

@ -1,10 +1,10 @@
'use client'
"use client";
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useToast } from '@/components/common/ToastContext'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { PageWithNav } from '@/components/PageWithNav'
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useToast } from "@/components/common/ToastContext";
import { useMyAbacus } from "@/contexts/MyAbacusContext";
import { PageWithNav } from "@/components/PageWithNav";
import {
ActiveSession,
type AttemptTimingData,
@ -13,18 +13,18 @@ import {
PracticeErrorBoundary,
PracticeSubNav,
type SessionHudData,
} from '@/components/practice'
import { GameBreakScreen } from '@/components/practice/GameBreakScreen'
import { GameBreakResultsScreen } from '@/components/practice/GameBreakResultsScreen'
import type { GameResultsReport } from '@/lib/arcade/game-sdk/types'
import type { Player } from '@/db/schema/players'
} from "@/components/practice";
import { GameBreakScreen } from "@/components/practice/GameBreakScreen";
import { GameBreakResultsScreen } from "@/components/practice/GameBreakResultsScreen";
import type { GameResultsReport } from "@/lib/arcade/game-sdk/types";
import type { Player } from "@/db/schema/players";
import type {
GameBreakSettings,
SessionHealth,
SessionPart,
SessionPlan,
SlotResult,
} from '@/db/schema/session-plans'
} from "@/db/schema/session-plans";
/**
* State for redoing a previously completed problem
@ -32,40 +32,40 @@ import type {
*/
export interface RedoState {
/** Whether redo mode is currently active */
isActive: boolean
isActive: boolean;
/** Linear index of the problem being redone (flat across all parts) */
linearIndex: number
linearIndex: number;
/** Part index containing the redo problem */
originalPartIndex: number
originalPartIndex: number;
/** Slot index within the part */
originalSlotIndex: number
originalSlotIndex: number;
/** The original result (to check if it was correct) */
originalResult: SlotResult
originalResult: SlotResult;
/** Part index to return to after redo */
returnToPartIndex: number
returnToPartIndex: number;
/** Slot index to return to after redo */
returnToSlotIndex: number
returnToSlotIndex: number;
}
import {
type ReceivedAbacusControl,
type TeacherPauseRequest,
useSessionBroadcast,
} from '@/hooks/useSessionBroadcast'
} from "@/hooks/useSessionBroadcast";
import {
sessionPlanKeys,
useActiveSessionPlan,
useEndSessionEarly,
useRecordRedoResult,
useRecordSlotResult,
} from '@/hooks/useSessionPlan'
import { useQueryClient } from '@tanstack/react-query'
import { useSaveGameResult } from '@/hooks/useGameResults'
import { css } from '../../../../styled-system/css'
} from "@/hooks/useSessionPlan";
import { useQueryClient } from "@tanstack/react-query";
import { useSaveGameResult } from "@/hooks/useGameResults";
import { css } from "../../../../styled-system/css";
interface PracticeClientProps {
studentId: string
player: Player
initialSession: SessionPlan
studentId: string;
player: Player;
initialSession: SessionPlan;
}
/**
@ -76,207 +76,238 @@ interface PracticeClientProps {
*
* When the session completes, it redirects to /summary.
*/
export function PracticeClient({ studentId, player, initialSession }: PracticeClientProps) {
const router = useRouter()
const { showError } = useToast()
const { setVisionFrameCallback } = useMyAbacus()
const queryClient = useQueryClient()
export function PracticeClient({
studentId,
player,
initialSession,
}: PracticeClientProps) {
const router = useRouter();
const { showError } = useToast();
const { setVisionFrameCallback } = useMyAbacus();
const queryClient = useQueryClient();
// Track pause state for HUD display (ActiveSession owns the modal and actual pause logic)
const [isPaused, setIsPaused] = useState(false)
const [isPaused, setIsPaused] = useState(false);
// Track timing data from ActiveSession for the sub-nav HUD
const [timingData, setTimingData] = useState<AttemptTimingData | null>(null)
const [timingData, setTimingData] = useState<AttemptTimingData | null>(null);
// Track broadcast state for session observation (digit-by-digit updates from ActiveSession)
const [broadcastState, setBroadcastState] = useState<BroadcastState | null>(null)
const [broadcastState, setBroadcastState] = useState<BroadcastState | null>(
null,
);
// Browse mode state - lifted here so PracticeSubNav can trigger it
const [isBrowseMode, setIsBrowseMode] = useState(false)
const [isBrowseMode, setIsBrowseMode] = useState(false);
// Browse index - lifted for navigation from SessionProgressIndicator
const [browseIndex, setBrowseIndex] = useState(0)
const [browseIndex, setBrowseIndex] = useState(0);
// Teacher abacus control - receives commands from observing teacher
const [teacherControl, setTeacherControl] = useState<ReceivedAbacusControl | null>(null)
const [teacherControl, setTeacherControl] =
useState<ReceivedAbacusControl | null>(null);
// Teacher-initiated pause/resume requests from observing teacher
const [teacherPauseRequest, setTeacherPauseRequest] = useState<TeacherPauseRequest | null>(null)
const [teacherResumeRequest, setTeacherResumeRequest] = useState(false)
const [teacherPauseRequest, setTeacherPauseRequest] =
useState<TeacherPauseRequest | null>(null);
const [teacherResumeRequest, setTeacherResumeRequest] = useState(false);
// Manual pause request from HUD
const [manualPauseRequest, setManualPauseRequest] = useState(false)
const [manualPauseRequest, setManualPauseRequest] = useState(false);
// Game break state
const [showGameBreak, setShowGameBreak] = useState(false)
const [gameBreakStartTime, setGameBreakStartTime] = useState<number>(Date.now())
const [showGameBreak, setShowGameBreak] = useState(false);
const [gameBreakStartTime, setGameBreakStartTime] = useState<number>(
Date.now(),
);
// Track pending game break - set when part transition happens, triggers after transition screen
const [pendingGameBreak, setPendingGameBreak] = useState(false)
const [pendingGameBreak, setPendingGameBreak] = useState(false);
// Game break results - captured when game completes to show on interstitial screen
const [gameBreakResults, setGameBreakResults] = useState<GameResultsReport | null>(null)
const [gameBreakResults, setGameBreakResults] =
useState<GameResultsReport | null>(null);
// Show results interstitial before returning to practice
const [showGameBreakResults, setShowGameBreakResults] = useState(false)
const [showGameBreakResults, setShowGameBreakResults] = useState(false);
// Track previous part index to detect part transitions
const previousPartIndexRef = useRef<number>(initialSession.currentPartIndex)
const previousPartIndexRef = useRef<number>(initialSession.currentPartIndex);
// Ref to store stopVisionRecording - populated later when useSessionBroadcast is called
// This allows early-defined callbacks to access the function
const stopRecordingRef = useRef<(() => void) | undefined>(undefined)
const stopRecordingRef = useRef<(() => void) | undefined>(undefined);
// Ref to store sendProblemMarker for timeline sync
const sendProblemMarkerRef = useRef<
| ((
problemNumber: number,
partIndex: number,
eventType: 'problem-shown' | 'answer-submitted' | 'feedback-shown',
isCorrect?: boolean
eventType: "problem-shown" | "answer-submitted" | "feedback-shown",
isCorrect?: boolean,
) => void)
| undefined
>(undefined)
>(undefined);
// Redo state - allows students to re-attempt any completed problem
const [redoState, setRedoState] = useState<RedoState | null>(null)
const [redoState, setRedoState] = useState<RedoState | null>(null);
// Dev shortcut: Ctrl+Shift+G to trigger game break for testing
useEffect(() => {
if (process.env.NODE_ENV !== 'development') return
if (process.env.NODE_ENV !== "development") return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key === 'G') {
e.preventDefault()
if (e.ctrlKey && e.shiftKey && e.key === "G") {
e.preventDefault();
setShowGameBreak((prev) => {
if (!prev) {
setGameBreakStartTime(Date.now())
setGameBreakStartTime(Date.now());
}
return !prev
})
return !prev;
});
}
}
};
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
// Session plan mutations
const recordResult = useRecordSlotResult()
const recordRedo = useRecordRedoResult()
const endEarly = useEndSessionEarly()
const recordResult = useRecordSlotResult();
const recordRedo = useRecordRedoResult();
const endEarly = useEndSessionEarly();
// Game results mutation - saves to scoreboard when game break completes
const saveGameResult = useSaveGameResult()
const saveGameResult = useSaveGameResult();
// Fetch active session plan from cache or API with server data as initial
const { data: fetchedPlan } = useActiveSessionPlan(studentId, initialSession)
const { data: fetchedPlan } = useActiveSessionPlan(studentId, initialSession);
// Current plan - mutations take priority, then fetched/cached data
const currentPlan = endEarly.data ?? recordResult.data ?? fetchedPlan ?? initialSession
const currentPlan =
endEarly.data ?? recordResult.data ?? fetchedPlan ?? initialSession;
// Game break settings from the session plan
const gameBreakSettings = currentPlan.gameBreakSettings as GameBreakSettings | null
const gameBreakSettings =
currentPlan.gameBreakSettings as GameBreakSettings | null;
// Build game config with skipSetupPhase merged into each game's config
// This allows games to start immediately without showing their setup screen
const gameBreakGameConfig = useMemo(() => {
const baseConfig = gameBreakSettings?.gameConfig ?? {}
const skipSetup = gameBreakSettings?.skipSetupPhase ?? true // Default to true for practice breaks
const baseConfig = gameBreakSettings?.gameConfig ?? {};
const skipSetup = gameBreakSettings?.skipSetupPhase ?? true; // Default to true for practice breaks
if (!skipSetup) {
return baseConfig
return baseConfig;
}
// Merge skipSetupPhase into each game's config
const mergedConfig: Record<string, Record<string, unknown>> = {}
const mergedConfig: Record<string, Record<string, unknown>> = {};
for (const [gameName, config] of Object.entries(baseConfig)) {
mergedConfig[gameName] = { ...config, skipSetupPhase: true }
mergedConfig[gameName] = { ...config, skipSetupPhase: true };
}
// Also add skipSetupPhase for the selected game if not already in config
const selectedGame = gameBreakSettings?.selectedGame
if (selectedGame && selectedGame !== 'random' && !mergedConfig[selectedGame]) {
mergedConfig[selectedGame] = { skipSetupPhase: true }
const selectedGame = gameBreakSettings?.selectedGame;
if (
selectedGame &&
selectedGame !== "random" &&
!mergedConfig[selectedGame]
) {
mergedConfig[selectedGame] = { skipSetupPhase: true };
}
return mergedConfig
return mergedConfig;
}, [
gameBreakSettings?.gameConfig,
gameBreakSettings?.skipSetupPhase,
gameBreakSettings?.selectedGame,
])
]);
// Compute HUD data from current plan
const currentPart = currentPlan.parts[currentPlan.currentPartIndex] as SessionPart | undefined
const sessionHealth = currentPlan.sessionHealth as SessionHealth | null
const currentPart = currentPlan.parts[currentPlan.currentPartIndex] as
| SessionPart
| undefined;
const sessionHealth = currentPlan.sessionHealth as SessionHealth | null;
// Calculate totals
const { totalProblems, completedProblems } = useMemo(() => {
const total = currentPlan.parts.reduce((sum, part) => sum + part.slots.length, 0)
let completed = 0
const total = currentPlan.parts.reduce(
(sum, part) => sum + part.slots.length,
0,
);
let completed = 0;
for (let i = 0; i < currentPlan.currentPartIndex; i++) {
completed += currentPlan.parts[i].slots.length
completed += currentPlan.parts[i].slots.length;
}
completed += currentPlan.currentSlotIndex
return { totalProblems: total, completedProblems: completed }
}, [currentPlan.parts, currentPlan.currentPartIndex, currentPlan.currentSlotIndex])
completed += currentPlan.currentSlotIndex;
return { totalProblems: total, completedProblems: completed };
}, [
currentPlan.parts,
currentPlan.currentPartIndex,
currentPlan.currentSlotIndex,
]);
// Pause handler - triggers manual pause in ActiveSession
const handlePause = useCallback(() => {
setManualPauseRequest(true)
}, [])
setManualPauseRequest(true);
}, []);
const handleResume = useCallback(() => {
setIsPaused(false)
}, [])
setIsPaused(false);
}, []);
// Handle recording an answer
const handleAnswer = useCallback(
async (result: Omit<SlotResult, 'timestamp' | 'partNumber'>): Promise<void> => {
async (
result: Omit<SlotResult, "timestamp" | "partNumber">,
): Promise<void> => {
try {
// Send problem marker for timeline sync (before mutation so it captures the submit moment)
const currentProblemNumber = currentPlan.currentSlotIndex + 1
const currentProblemNumber = currentPlan.currentSlotIndex + 1;
sendProblemMarkerRef.current?.(
currentProblemNumber,
currentPlan.currentPartIndex,
'answer-submitted',
result.isCorrect
)
"answer-submitted",
result.isCorrect,
);
const previousPartIndex = previousPartIndexRef.current
const previousPartIndex = previousPartIndexRef.current;
const updatedPlan = await recordResult.mutateAsync({
playerId: studentId,
planId: currentPlan.id,
result,
})
});
// Update previous part index tracking
previousPartIndexRef.current = updatedPlan.currentPartIndex
previousPartIndexRef.current = updatedPlan.currentPartIndex;
// If session just completed, redirect to summary with completed flag
if (updatedPlan.completedAt) {
// Stop vision recording if it was started
stopRecordingRef.current?.()
stopRecordingRef.current?.();
router.push(`/practice/${studentId}/summary?completed=1`, {
scroll: false,
})
return
});
return;
}
// Check for part transition - queue game break to show AFTER transition screen
const partTransitioned = updatedPlan.currentPartIndex > previousPartIndex
const hasMoreParts = updatedPlan.currentPartIndex < updatedPlan.parts.length
const partTransitioned =
updatedPlan.currentPartIndex > previousPartIndex;
const hasMoreParts =
updatedPlan.currentPartIndex < updatedPlan.parts.length;
const gameBreakEnabled =
(updatedPlan.gameBreakSettings as GameBreakSettings | null)?.enabled ?? false
(updatedPlan.gameBreakSettings as GameBreakSettings | null)
?.enabled ?? false;
if (partTransitioned && hasMoreParts && gameBreakEnabled) {
console.log(
`[PracticeClient] Part completed (${previousPartIndex}${updatedPlan.currentPartIndex}), queuing game break for after transition screen`
)
`[PracticeClient] Part completed (${previousPartIndex}${updatedPlan.currentPartIndex}), queuing game break for after transition screen`,
);
// Don't show game break immediately - wait for transition screen to complete
// The game break will be triggered when onPartTransitionComplete is called
setPendingGameBreak(true)
setPendingGameBreak(true);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
if (message.includes('Not authorized')) {
const message = err instanceof Error ? err.message : "Unknown error";
if (message.includes("Not authorized")) {
showError(
'Not authorized',
'Only parents or teachers with the student present in their classroom can record answers.'
)
"Not authorized",
"Only parents or teachers with the student present in their classroom can record answers.",
);
} else {
showError('Failed to record answer', message)
showError("Failed to record answer", message);
}
}
},
[studentId, currentPlan.id, recordResult, router, showError]
)
[studentId, currentPlan.id, recordResult, router, showError],
);
// Handle ending session early
const handleEndEarly = useCallback(
@ -286,59 +317,59 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
playerId: studentId,
planId: currentPlan.id,
reason,
})
});
// Stop vision recording if it was started
stopRecordingRef.current?.()
stopRecordingRef.current?.();
// Redirect to summary after ending early with completed flag
router.push(`/practice/${studentId}/summary?completed=1`, {
scroll: false,
})
});
} catch (err) {
// Check if it's an authorization error
const message = err instanceof Error ? err.message : 'Unknown error'
if (message.includes('Not authorized')) {
const message = err instanceof Error ? err.message : "Unknown error";
if (message.includes("Not authorized")) {
showError(
'Not authorized',
'Only parents or teachers with the student present in their classroom can end sessions.'
)
"Not authorized",
"Only parents or teachers with the student present in their classroom can end sessions.",
);
} else {
showError('Failed to end session', message)
showError("Failed to end session", message);
}
}
},
[studentId, currentPlan.id, endEarly, router, showError]
)
[studentId, currentPlan.id, endEarly, router, showError],
);
// Handle session completion (called by ActiveSession when all problems done)
const handleSessionComplete = useCallback(() => {
// Stop vision recording if it was started
stopRecordingRef.current?.()
stopRecordingRef.current?.();
// Redirect to summary with completed flag
router.push(`/practice/${studentId}/summary?completed=1`, {
scroll: false,
})
}, [studentId, router])
});
}, [studentId, router]);
// Handle redoing a previously completed problem
// Called when student taps a completed problem dot in the progress indicator
const handleRedoProblem = useCallback(
(linearIndex: number, originalResult: SlotResult) => {
// Find the part and slot for this linear index
let partIndex = 0
let remaining = linearIndex
let partIndex = 0;
let remaining = linearIndex;
for (let i = 0; i < currentPlan.parts.length; i++) {
const partSlotCount = currentPlan.parts[i].slots.length
const partSlotCount = currentPlan.parts[i].slots.length;
if (remaining < partSlotCount) {
partIndex = i
break
partIndex = i;
break;
}
remaining -= partSlotCount
remaining -= partSlotCount;
}
const slotIndex = remaining
const slotIndex = remaining;
// Exit browse mode if active
if (isBrowseMode) {
setIsBrowseMode(false)
setIsBrowseMode(false);
}
// Set redo state
@ -350,46 +381,54 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
originalResult,
returnToPartIndex: currentPlan.currentPartIndex,
returnToSlotIndex: currentPlan.currentSlotIndex,
})
});
},
[currentPlan.parts, currentPlan.currentPartIndex, currentPlan.currentSlotIndex, isBrowseMode]
)
[
currentPlan.parts,
currentPlan.currentPartIndex,
currentPlan.currentSlotIndex,
isBrowseMode,
],
);
// Handle canceling a redo - exit without recording
const handleCancelRedo = useCallback(() => {
setRedoState(null)
}, [])
setRedoState(null);
}, []);
// Handle game break end - show results screen if game finished normally
const handleGameBreakEnd = useCallback(
(reason: 'timeout' | 'gameFinished' | 'skipped', results?: GameResultsReport) => {
setShowGameBreak(false)
(
reason: "timeout" | "gameFinished" | "skipped",
results?: GameResultsReport,
) => {
setShowGameBreak(false);
// If game finished normally with results, save to scoreboard and show interstitial
if (reason === 'gameFinished' && results) {
if (reason === "gameFinished" && results) {
// Save result to database for scoreboard
saveGameResult.mutate({
playerId: player.id,
sessionType: 'practice-break',
sessionType: "practice-break",
sessionId: currentPlan.id,
report: results,
})
});
setGameBreakResults(results)
setShowGameBreakResults(true)
setGameBreakResults(results);
setShowGameBreakResults(true);
} else {
// Timeout or skip - no results to show, return to practice immediately
setGameBreakResults(null)
setGameBreakResults(null);
}
},
[saveGameResult, player.id, currentPlan.id]
)
[saveGameResult, player.id, currentPlan.id],
);
// Handle results screen completion - return to practice
const handleGameBreakResultsComplete = useCallback(() => {
setShowGameBreakResults(false)
setGameBreakResults(null)
}, [])
setShowGameBreakResults(false);
setGameBreakResults(null);
}, []);
// Broadcast session state if student is in a classroom
// broadcastState is updated by ActiveSession via the onBroadcastStateChange callback
@ -407,80 +446,121 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
onAbacusControl: setTeacherControl,
onTeacherPause: setTeacherPauseRequest,
onTeacherResume: () => setTeacherResumeRequest(true),
})
});
// Track whether we've started vision recording for this session
const hasStartedRecordingRef = useRef(false)
const hasStartedRecordingRef = useRef(false);
// Track previous problem number to detect when new problems appear
const previousProblemNumberRef = useRef<number | null>(null)
const previousProblemNumberRef = useRef<number | null>(null);
// Track previous isRecording state to detect when recording starts
const wasRecordingRef = useRef(false);
// Update the refs so callbacks defined earlier can access these functions
stopRecordingRef.current = stopVisionRecording
sendProblemMarkerRef.current = sendProblemMarker
stopRecordingRef.current = stopVisionRecording;
sendProblemMarkerRef.current = sendProblemMarker;
// When recording starts, emit problem-shown marker for the current problem
// This handles the case where recording starts mid-session after the first problem-shown was dropped
useEffect(() => {
const wasRecording = wasRecordingRef.current;
wasRecordingRef.current = isRecording;
// Detect transition from not-recording to recording
if (!wasRecording && isRecording && broadcastState) {
const currentProblemNumber = broadcastState.currentProblemNumber;
console.log(
`[PracticeClient] Recording just started, emitting problem-shown for current problem ${currentProblemNumber}`,
);
sendProblemMarker(
currentProblemNumber,
broadcastState.currentPartIndex ?? currentPlan.currentPartIndex,
"problem-shown",
);
}
}, [
isRecording,
broadcastState,
currentPlan.currentPartIndex,
sendProblemMarker,
]);
// Emit 'problem-shown' marker when a new problem appears (including first problem)
useEffect(() => {
if (!broadcastState) return
if (!broadcastState) return;
const currentProblemNumber = broadcastState.currentProblemNumber
const previousProblemNumber = previousProblemNumberRef.current
const currentProblemNumber = broadcastState.currentProblemNumber;
const previousProblemNumber = previousProblemNumberRef.current;
// Emit if this is a new problem OR the first problem (previousProblemNumber is null)
if (currentProblemNumber !== previousProblemNumber) {
console.log(
`[PracticeClient] Problem shown: ${previousProblemNumber ?? 'none'}${currentProblemNumber}, emitting problem-shown marker`
)
`[PracticeClient] Problem shown: ${previousProblemNumber ?? "none"}${currentProblemNumber}, emitting problem-shown marker`,
);
sendProblemMarker(
currentProblemNumber,
broadcastState.currentPartIndex ?? currentPlan.currentPartIndex,
'problem-shown'
)
"problem-shown",
);
}
// Update ref for next comparison
previousProblemNumberRef.current = currentProblemNumber
}, [broadcastState?.currentProblemNumber, broadcastState?.currentPartIndex, currentPlan.currentPartIndex, sendProblemMarker])
previousProblemNumberRef.current = currentProblemNumber;
}, [
broadcastState?.currentProblemNumber,
broadcastState?.currentPartIndex,
currentPlan.currentPartIndex,
sendProblemMarker,
]);
// Handle part transition complete - called when transition screen finishes
// This is where we trigger game break (after "put away abacus" message is shown)
const handlePartTransitionComplete = useCallback(() => {
// First, broadcast to observers
sendPartTransitionComplete()
sendPartTransitionComplete();
// Then, check if we have a pending game break
if (pendingGameBreak) {
console.log('[PracticeClient] Transition screen complete, now showing game break')
setGameBreakStartTime(Date.now())
setShowGameBreak(true)
setPendingGameBreak(false)
console.log(
"[PracticeClient] Transition screen complete, now showing game break",
);
setGameBreakStartTime(Date.now());
setShowGameBreak(true);
setPendingGameBreak(false);
}
}, [sendPartTransitionComplete, pendingGameBreak])
}, [sendPartTransitionComplete, pendingGameBreak]);
// Wire vision frame callback to broadcast vision frames to observers
// Also auto-start recording when vision frames start flowing
useEffect(() => {
console.log('[PracticeClient] Setting up vision frame callback')
console.log("[PracticeClient] Setting up vision frame callback");
setVisionFrameCallback((frame) => {
console.log(
'[PracticeClient] Vision frame received, hasStartedRecording:',
"[PracticeClient] Vision frame received, hasStartedRecording:",
hasStartedRecordingRef.current,
'isRecording:',
isRecording
)
"isRecording:",
isRecording,
);
// Start recording on first vision frame (if not already recording)
if (!hasStartedRecordingRef.current && !isRecording) {
console.log('[PracticeClient] First vision frame received, calling startVisionRecording()')
hasStartedRecordingRef.current = true
startVisionRecording()
console.log(
"[PracticeClient] First vision frame received, calling startVisionRecording()",
);
hasStartedRecordingRef.current = true;
startVisionRecording();
}
sendVisionFrame(frame.imageData, frame.detectedValue, frame.confidence)
})
sendVisionFrame(frame.imageData, frame.detectedValue, frame.confidence);
});
return () => {
setVisionFrameCallback(null)
}
}, [setVisionFrameCallback, sendVisionFrame, isRecording, startVisionRecording])
setVisionFrameCallback(null);
};
}, [
setVisionFrameCallback,
sendVisionFrame,
isRecording,
startVisionRecording,
]);
// Build session HUD data for PracticeSubNav
const sessionHud: SessionHudData | undefined = currentPart
@ -514,7 +594,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
: undefined,
onPause: handlePause,
onResume: handleResume,
onEndEarly: () => handleEndEarly('Session ended'),
onEndEarly: () => handleEndEarly("Session ended"),
isEndingSession: endEarly.isPending,
isBrowseMode,
onToggleBrowse: () => setIsBrowseMode((prev) => !prev),
@ -523,7 +603,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
redoLinearIndex: redoState?.linearIndex,
plan: currentPlan,
}
: undefined
: undefined;
// Build game break HUD data for PracticeSubNav (when on game break)
const gameBreakHud: GameBreakHudData | undefined = showGameBreak
@ -532,7 +612,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
maxDurationMs: (gameBreakSettings?.maxDurationMinutes ?? 5) * 60 * 1000,
onSkip: handleGameBreakEnd,
}
: undefined
: undefined;
return (
<PageWithNav>
@ -548,16 +628,16 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
data-component="practice-page"
className={css({
// Fixed positioning to precisely control bounds
position: 'fixed',
position: "fixed",
// Top: main nav (80px) + sub-nav height (~52px mobile, ~60px desktop)
top: { base: '132px', md: '140px' },
top: { base: "132px", md: "140px" },
left: 0,
// Right: 0 by default, landscape mobile handled via media query below
right: 0,
// Bottom: keypad height on mobile portrait (48px), 0 on desktop
// Landscape mobile handled via media query below
bottom: { base: '48px', md: 0 },
overflow: 'hidden', // Prevent scrolling during practice
bottom: { base: "48px", md: 0 },
overflow: "hidden", // Prevent scrolling during practice
})}
>
{/* Landscape mobile: keypad is on right (100px) instead of bottom */}
@ -593,7 +673,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
maxDurationMinutes={gameBreakSettings?.maxDurationMinutes ?? 5}
startTime={gameBreakStartTime}
onComplete={handleGameBreakEnd}
selectionMode={gameBreakSettings?.selectionMode ?? 'kid-chooses'}
selectionMode={gameBreakSettings?.selectionMode ?? "kid-chooses"}
selectedGame={gameBreakSettings?.selectedGame ?? null}
gameConfig={gameBreakGameConfig}
/>
@ -634,12 +714,12 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
// Invalidate the session plan query to refetch updated results
queryClient.invalidateQueries({
queryKey: sessionPlanKeys.active(studentId),
})
});
}}
/>
)}
</PracticeErrorBoundary>
</main>
</PageWithNav>
)
);
}

File diff suppressed because it is too large Load Diff