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:
parent
371b449d15
commit
fbbbf9f50b
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue