feat(practice): add game break settings infrastructure

Add the foundational infrastructure for game break settings without
the room/player context integration:

- Add GameBreakSettings interface to session-plans schema
- Add gameBreakSettings column to session_plans DB table
- Add toggle and duration selector UI to StartPracticeModal
- Create useGameBreakTimer hook for countdown timer logic
- Add unit tests for useGameBreakTimer (9 tests)
- Add id field to StudentInfo interface for future player context
- Update stories with gameBreakSettings mock data

The room creation and player joining logic still needs iteration
to properly align browser viewerId with student DBPlayer identity.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2026-01-05 11:14:15 -06:00
parent 91884c4dfc
commit ef8b1be413
12 changed files with 3263 additions and 3227 deletions

View File

@@ -1,6 +1,6 @@
import { type NextRequest, NextResponse } from "next/server";
import type { SessionPlan } from "@/db/schema/session-plans";
import { canPerformAction } from "@/lib/classroom";
import { type NextRequest, NextResponse } from 'next/server'
import type { SessionPlan, GameBreakSettings } from '@/db/schema/session-plans'
import { canPerformAction } from '@/lib/classroom'
import {
ActiveSessionExistsError,
type EnabledParts,
@@ -8,13 +8,13 @@ import {
generateSessionPlan,
getActiveSessionPlan,
NoSkillsEnabledError,
} from "@/lib/curriculum";
import type { ProblemGenerationMode } from "@/lib/curriculum/config";
import type { SessionMode } from "@/lib/curriculum/session-mode";
import { getDbUserId } from "@/lib/viewer";
} from '@/lib/curriculum'
import type { ProblemGenerationMode } from '@/lib/curriculum/config'
import type { SessionMode } from '@/lib/curriculum/session-mode'
import { getDbUserId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ playerId: string }>;
params: Promise<{ playerId: string }>
}
/**
@@ -24,23 +24,11 @@ interface RouteParams {
function serializePlan(plan: SessionPlan) {
return {
...plan,
createdAt:
plan.createdAt instanceof Date
? plan.createdAt.getTime()
: plan.createdAt,
approvedAt:
plan.approvedAt instanceof Date
? plan.approvedAt.getTime()
: plan.approvedAt,
startedAt:
plan.startedAt instanceof Date
? plan.startedAt.getTime()
: plan.startedAt,
completedAt:
plan.completedAt instanceof Date
? plan.completedAt.getTime()
: plan.completedAt,
};
createdAt: plan.createdAt instanceof Date ? plan.createdAt.getTime() : plan.createdAt,
approvedAt: plan.approvedAt instanceof Date ? plan.approvedAt.getTime() : plan.approvedAt,
startedAt: plan.startedAt instanceof Date ? plan.startedAt.getTime() : plan.startedAt,
completedAt: plan.completedAt instanceof Date ? plan.completedAt.getTime() : plan.completedAt,
}
}
/**
@@ -48,24 +36,21 @@ function serializePlan(plan: SessionPlan) {
* Get the active session plan for a player (if any)
*/
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { playerId } = await params;
const { playerId } = await params
try {
// Authorization check
const userId = await getDbUserId();
const canView = await canPerformAction(userId, playerId, "view");
const userId = await getDbUserId()
const canView = await canPerformAction(userId, playerId, 'view')
if (!canView) {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const plan = await getActiveSessionPlan(playerId);
return NextResponse.json({ plan: plan ? serializePlan(plan) : null });
const plan = await getActiveSessionPlan(playerId)
return NextResponse.json({ plan: plan ? serializePlan(plan) : null })
} catch (error) {
console.error("Error fetching active plan:", error);
return NextResponse.json(
{ error: "Failed to fetch active plan" },
{ status: 500 },
);
console.error('Error fetching active plan:', error)
return NextResponse.json({ error: 'Failed to fetch active plan' }, { status: 500 })
}
}
@@ -89,17 +74,17 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
* - Part 3: Linear (mental math, sentence format)
*/
export async function POST(request: NextRequest, { params }: RouteParams) {
const { playerId } = await params;
const { playerId } = await params
try {
// Authorization check - only parents/present teachers can create sessions
const userId = await getDbUserId();
const canCreate = await canPerformAction(userId, playerId, "start-session");
const userId = await getDbUserId()
const canCreate = await canPerformAction(userId, playerId, 'start-session')
if (!canCreate) {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const body = await request.json();
const body = await request.json()
const {
durationMinutes,
abacusTermCount,
@@ -107,66 +92,54 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
problemGenerationMode,
confidenceThreshold,
sessionMode,
} = body;
gameBreakSettings,
} = body
if (!durationMinutes || typeof durationMinutes !== "number") {
if (!durationMinutes || typeof durationMinutes !== 'number') {
return NextResponse.json(
{ error: "durationMinutes is required and must be a number" },
{ status: 400 },
);
{ error: 'durationMinutes is required and must be a number' },
{ status: 400 }
)
}
// Validate enabledParts if provided
if (enabledParts) {
const validParts = ["abacus", "visualization", "linear"];
const enabledCount = validParts.filter(
(p) => enabledParts[p] === true,
).length;
const validParts = ['abacus', 'visualization', 'linear']
const enabledCount = validParts.filter((p) => enabledParts[p] === true).length
if (enabledCount === 0) {
return NextResponse.json(
{ error: "At least one part must be enabled" },
{ status: 400 },
);
return NextResponse.json({ error: 'At least one part must be enabled' }, { status: 400 })
}
}
const options: GenerateSessionPlanOptions = {
playerId,
durationMinutes,
// Pass enabled parts
enabledParts: enabledParts as EnabledParts | undefined,
// Pass problem generation mode if specified
problemGenerationMode: problemGenerationMode as
| ProblemGenerationMode
| undefined,
// Pass BKT confidence threshold if specified
problemGenerationMode: problemGenerationMode as ProblemGenerationMode | undefined,
confidenceThreshold:
typeof confidenceThreshold === "number"
? confidenceThreshold
: undefined,
// Pass session mode for single source of truth targeting
typeof confidenceThreshold === 'number' ? confidenceThreshold : undefined,
sessionMode: sessionMode as SessionMode | undefined,
// Pass config overrides if abacusTermCount is specified
gameBreakSettings: gameBreakSettings as GameBreakSettings | undefined,
...(abacusTermCount && {
config: {
abacusTermCount,
},
}),
};
}
const plan = await generateSessionPlan(options);
return NextResponse.json({ plan: serializePlan(plan) }, { status: 201 });
const plan = await generateSessionPlan(options)
return NextResponse.json({ plan: serializePlan(plan) }, { status: 201 })
} catch (error) {
// Handle active session conflict
if (error instanceof ActiveSessionExistsError) {
return NextResponse.json(
{
error: "Active session exists",
code: "ACTIVE_SESSION_EXISTS",
error: 'Active session exists',
code: 'ACTIVE_SESSION_EXISTS',
existingPlan: serializePlan(error.existingSession),
},
{ status: 409 },
);
{ status: 409 }
)
}
// Handle no skills enabled
@@ -174,16 +147,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json(
{
error: error.message,
code: "NO_SKILLS_ENABLED",
code: 'NO_SKILLS_ENABLED',
},
{ status: 400 },
);
{ status: 400 }
)
}
console.error("Error generating session plan:", error);
return NextResponse.json(
{ error: "Failed to generate session plan" },
{ status: 500 },
);
console.error('Error generating session plan:', error)
return NextResponse.json({ error: 'Failed to generate session plan' }, { status: 500 })
}
}

View File

@@ -1,10 +1,10 @@
"use client";
'use client'
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, 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, useState } from 'react'
import { useToast } from '@/components/common/ToastContext'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { PageWithNav } from '@/components/PageWithNav'
import {
ActiveSession,
type AttemptTimingData,
@@ -12,30 +12,25 @@ import {
PracticeErrorBoundary,
PracticeSubNav,
type SessionHudData,
} from "@/components/practice";
import type { Player } from "@/db/schema/players";
import type {
SessionHealth,
SessionPart,
SessionPlan,
SlotResult,
} from "@/db/schema/session-plans";
} from '@/components/practice'
import type { Player } from '@/db/schema/players'
import type { SessionHealth, SessionPart, SessionPlan, SlotResult } from '@/db/schema/session-plans'
import {
type ReceivedAbacusControl,
type TeacherPauseRequest,
useSessionBroadcast,
} from "@/hooks/useSessionBroadcast";
} from '@/hooks/useSessionBroadcast'
import {
useActiveSessionPlan,
useEndSessionEarly,
useRecordSlotResult,
} from "@/hooks/useSessionPlan";
import { css } from "../../../../styled-system/css";
} from '@/hooks/useSessionPlan'
import { css } from '../../../../styled-system/css'
interface PracticeClientProps {
studentId: string;
player: Player;
initialSession: SessionPlan;
studentId: string
player: Player
initialSession: SessionPlan
}
/**
@@ -46,113 +41,93 @@ 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();
export function PracticeClient({ studentId, player, initialSession }: PracticeClientProps) {
const router = useRouter()
const { showError } = useToast()
const { setVisionFrameCallback } = useMyAbacus()
// 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)
// Session plan mutations
const recordResult = useRecordSlotResult();
const endEarly = useEndSessionEarly();
const recordResult = useRecordSlotResult()
const endEarly = useEndSessionEarly()
// 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
// 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 {
const updatedPlan = await recordResult.mutateAsync({
playerId: studentId,
planId: currentPlan.id,
result,
});
})
// If session just completed, redirect to summary with completed flag
if (updatedPlan.completedAt) {
router.push(`/practice/${studentId}/summary?completed=1`, {
scroll: false,
});
})
}
} 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(
@@ -162,56 +137,60 @@ export function PracticeClient({
playerId: studentId,
planId: currentPlan.id,
reason,
});
})
// 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(() => {
// Redirect to summary with completed flag
router.push(`/practice/${studentId}/summary?completed=1`, {
scroll: false,
});
}, [studentId, router]);
})
}, [studentId, router])
// Broadcast session state if student is in a classroom
// broadcastState is updated by ActiveSession via the onBroadcastStateChange callback
// onAbacusControl receives control events from observing teacher
// onTeacherPause/onTeacherResume receive pause/resume commands from teacher
const { sendPartTransition, sendPartTransitionComplete, sendVisionFrame } =
useSessionBroadcast(currentPlan.id, studentId, broadcastState, {
const { sendPartTransition, sendPartTransitionComplete, sendVisionFrame } = useSessionBroadcast(
currentPlan.id,
studentId,
broadcastState,
{
onAbacusControl: setTeacherControl,
onTeacherPause: setTeacherPauseRequest,
onTeacherResume: () => setTeacherResumeRequest(true),
});
}
)
// Wire vision frame callback to broadcast vision frames to observers
useEffect(() => {
setVisionFrameCallback((frame) => {
sendVisionFrame(frame.imageData, frame.detectedValue, frame.confidence);
});
sendVisionFrame(frame.imageData, frame.detectedValue, frame.confidence)
})
return () => {
setVisionFrameCallback(null);
};
}, [setVisionFrameCallback, sendVisionFrame]);
setVisionFrameCallback(null)
}
}, [setVisionFrameCallback, sendVisionFrame])
// Build session HUD data for PracticeSubNav
const sessionHud: SessionHudData | undefined = currentPart
@@ -245,38 +224,34 @@ export function PracticeClient({
: undefined,
onPause: handlePause,
onResume: handleResume,
onEndEarly: () => handleEndEarly("Session ended"),
onEndEarly: () => handleEndEarly('Session ended'),
isEndingSession: endEarly.isPending,
isBrowseMode,
onToggleBrowse: () => setIsBrowseMode((prev) => !prev),
onBrowseNavigate: setBrowseIndex,
plan: currentPlan,
}
: undefined;
: undefined
return (
<PageWithNav>
{/* Practice Sub-Navigation with Session HUD */}
<PracticeSubNav
student={player}
pageContext="session"
sessionHud={sessionHud}
/>
<PracticeSubNav student={player} pageContext="session" sessionHud={sessionHud} />
<main
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 */}
@@ -296,6 +271,7 @@ export function PracticeClient({
<ActiveSession
plan={currentPlan}
student={{
id: player.id,
name: player.name,
emoji: player.emoji,
color: player.color,
@@ -324,5 +300,5 @@ export function PracticeClient({
</PracticeErrorBoundary>
</main>
</PageWithNav>
);
)
}

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useCallback, useState } from "react";
import type { Meta, StoryObj } from '@storybook/react'
import { useCallback, useState } from 'react'
import type {
GeneratedProblem,
ProblemSlot,
@@ -8,89 +8,101 @@ import type {
SessionPlan,
SessionSummary,
SlotResult,
} from "@/db/schema/session-plans";
import { createBasicSkillSet } from "@/types/tutorial";
} from '@/db/schema/session-plans'
import { createBasicSkillSet } from '@/types/tutorial'
import {
analyzeRequiredSkills,
type ProblemConstraints as GeneratorConstraints,
generateSingleProblem,
} from "@/utils/problemGenerator";
import { css } from "../../../styled-system/css";
import { ActiveSession, type StudentInfo } from "./ActiveSession";
} from '@/utils/problemGenerator'
import { css } from '../../../styled-system/css'
import { ActiveSession, type StudentInfo } from './ActiveSession'
/**
* Create a mock student for stories
*/
function createMockStudent(name: string): StudentInfo {
const students: Record<string, StudentInfo> = {
Sonia: { name: "Sonia", emoji: "🌟", color: "purple" },
Marcus: { name: "Marcus", emoji: "🚀", color: "blue" },
Luna: { name: "Luna", emoji: "🌙", color: "indigo" },
Kai: { name: "Kai", emoji: "🌊", color: "cyan" },
};
return students[name] ?? { name, emoji: "🎓", color: "gray" };
Sonia: { id: 'student-sonia', name: 'Sonia', emoji: '🌟', color: 'purple' },
Marcus: {
id: 'student-marcus',
name: 'Marcus',
emoji: '🚀',
color: 'blue',
},
Luna: { id: 'student-luna', name: 'Luna', emoji: '🌙', color: 'indigo' },
Kai: { id: 'student-kai', name: 'Kai', emoji: '🌊', color: 'cyan' },
}
return (
students[name] ?? {
id: `student-${name.toLowerCase()}`,
name,
emoji: '🎓',
color: 'gray',
}
)
}
const meta: Meta<typeof ActiveSession> = {
title: "Practice/ActiveSession",
title: 'Practice/ActiveSession',
component: ActiveSession,
parameters: {
layout: "fullscreen",
layout: 'fullscreen',
},
tags: ["autodocs"],
};
tags: ['autodocs'],
}
export default meta;
type Story = StoryObj<typeof ActiveSession>;
export default meta
type Story = StoryObj<typeof ActiveSession>
/**
* Generate a skill-appropriate problem
*/
function generateProblemWithSkills(
skillLevel: "basic" | "fiveComplements" | "tenComplements",
skillLevel: 'basic' | 'fiveComplements' | 'tenComplements'
): GeneratedProblem {
const baseSkills = createBasicSkillSet();
const baseSkills = createBasicSkillSet()
baseSkills.basic.directAddition = true;
baseSkills.basic.heavenBead = true;
baseSkills.basic.simpleCombinations = true;
baseSkills.basic.directAddition = true
baseSkills.basic.heavenBead = true
baseSkills.basic.simpleCombinations = true
if (skillLevel === "fiveComplements" || skillLevel === "tenComplements") {
baseSkills.fiveComplements["4=5-1"] = true;
baseSkills.fiveComplements["3=5-2"] = true;
baseSkills.fiveComplements["2=5-3"] = true;
baseSkills.fiveComplements["1=5-4"] = true;
if (skillLevel === 'fiveComplements' || skillLevel === 'tenComplements') {
baseSkills.fiveComplements['4=5-1'] = true
baseSkills.fiveComplements['3=5-2'] = true
baseSkills.fiveComplements['2=5-3'] = true
baseSkills.fiveComplements['1=5-4'] = true
}
if (skillLevel === "tenComplements") {
baseSkills.tenComplements["9=10-1"] = true;
baseSkills.tenComplements["8=10-2"] = true;
baseSkills.tenComplements["7=10-3"] = true;
if (skillLevel === 'tenComplements') {
baseSkills.tenComplements['9=10-1'] = true
baseSkills.tenComplements['8=10-2'] = true
baseSkills.tenComplements['7=10-3'] = true
}
const constraints: GeneratorConstraints = {
numberRange: { min: 1, max: skillLevel === "tenComplements" ? 99 : 9 },
numberRange: { min: 1, max: skillLevel === 'tenComplements' ? 99 : 9 },
maxTerms: 4,
problemCount: 1,
};
}
const problem = generateSingleProblem(constraints, baseSkills);
const problem = generateSingleProblem(constraints, baseSkills)
if (problem) {
return {
terms: problem.terms,
answer: problem.answer,
skillsRequired: problem.skillsUsed,
};
}
}
// Fallback
const terms = [3, 4, 2];
const terms = [3, 4, 2]
return {
terms,
answer: terms.reduce((a, b) => a + b, 0),
skillsRequired: analyzeRequiredSkills(terms, 9),
};
}
}
/**
@@ -98,132 +110,113 @@ function generateProblemWithSkills(
*/
function createMockSlotsWithProblems(
count: number,
skillLevel: "basic" | "fiveComplements" | "tenComplements",
purposes: Array<"focus" | "reinforce" | "review" | "challenge"> = [
"focus",
"reinforce",
"review",
],
skillLevel: 'basic' | 'fiveComplements' | 'tenComplements',
purposes: Array<'focus' | 'reinforce' | 'review' | 'challenge'> = ['focus', 'reinforce', 'review']
): ProblemSlot[] {
return Array.from({ length: count }, (_, i) => {
// Build required skills based on skill level
// Using type assertion since we're building mock data with partial values
const allowedSkills: ProblemSlot["constraints"]["allowedSkills"] = {
const allowedSkills: ProblemSlot['constraints']['allowedSkills'] = {
basic: { directAddition: true, heavenBead: true },
...(skillLevel !== "basic" && {
fiveComplements: { "4=5-1": true, "3=5-2": true },
...(skillLevel !== 'basic' && {
fiveComplements: { '4=5-1': true, '3=5-2': true },
}),
...(skillLevel === "tenComplements" && {
tenComplements: { "9=10-1": true, "8=10-2": true },
...(skillLevel === 'tenComplements' && {
tenComplements: { '9=10-1': true, '8=10-2': true },
}),
} as ProblemSlot["constraints"]["allowedSkills"];
} as ProblemSlot['constraints']['allowedSkills']
return {
index: i,
purpose: purposes[i % purposes.length],
constraints: {
allowedSkills,
digitRange: { min: 1, max: skillLevel === "tenComplements" ? 2 : 1 },
digitRange: { min: 1, max: skillLevel === 'tenComplements' ? 2 : 1 },
termCount: { min: 3, max: 4 },
},
problem: generateProblemWithSkills(skillLevel),
};
});
}
})
}
/**
* Create a complete mock session plan with generated problems
*/
function createMockSessionPlanWithProblems(config: {
totalProblems?: number;
skillLevel?: "basic" | "fiveComplements" | "tenComplements";
currentPartIndex?: number;
currentSlotIndex?: number;
sessionHealth?: SessionHealth | null;
totalProblems?: number
skillLevel?: 'basic' | 'fiveComplements' | 'tenComplements'
currentPartIndex?: number
currentSlotIndex?: number
sessionHealth?: SessionHealth | null
}): SessionPlan {
const totalProblems = config.totalProblems || 15;
const skillLevel = config.skillLevel || "basic";
const totalProblems = config.totalProblems || 15
const skillLevel = config.skillLevel || 'basic'
const part1Count = Math.round(totalProblems * 0.5);
const part2Count = Math.round(totalProblems * 0.3);
const part3Count = totalProblems - part1Count - part2Count;
const part1Count = Math.round(totalProblems * 0.5)
const part2Count = Math.round(totalProblems * 0.3)
const part3Count = totalProblems - part1Count - part2Count
const parts: SessionPart[] = [
{
partNumber: 1,
type: "abacus",
format: "vertical",
type: 'abacus',
format: 'vertical',
useAbacus: true,
slots: createMockSlotsWithProblems(part1Count, skillLevel, [
"focus",
"focus",
"reinforce",
]),
slots: createMockSlotsWithProblems(part1Count, skillLevel, ['focus', 'focus', 'reinforce']),
estimatedMinutes: 5,
},
{
partNumber: 2,
type: "visualization",
format: "vertical",
type: 'visualization',
format: 'vertical',
useAbacus: false,
slots: createMockSlotsWithProblems(part2Count, skillLevel, [
"focus",
"reinforce",
"review",
]),
slots: createMockSlotsWithProblems(part2Count, skillLevel, ['focus', 'reinforce', 'review']),
estimatedMinutes: 3,
},
{
partNumber: 3,
type: "linear",
format: "linear",
type: 'linear',
format: 'linear',
useAbacus: false,
slots: createMockSlotsWithProblems(part3Count, skillLevel, [
"review",
"challenge",
]),
slots: createMockSlotsWithProblems(part3Count, skillLevel, ['review', 'challenge']),
estimatedMinutes: 2,
},
];
]
const summary: SessionSummary = {
focusDescription:
skillLevel === "tenComplements"
? "Ten Complements"
: skillLevel === "fiveComplements"
? "Five Complements"
: "Basic Addition",
skillLevel === 'tenComplements'
? 'Ten Complements'
: skillLevel === 'fiveComplements'
? 'Five Complements'
: 'Basic Addition',
totalProblemCount: totalProblems,
estimatedMinutes: 10,
parts: parts.map((p) => ({
partNumber: p.partNumber,
type: p.type,
description:
p.type === "abacus"
? "Use Abacus"
: p.type === "visualization"
? "Mental Math (Visualization)"
: "Mental Math (Linear)",
p.type === 'abacus'
? 'Use Abacus'
: p.type === 'visualization'
? 'Mental Math (Visualization)'
: 'Mental Math (Linear)',
problemCount: p.slots.length,
estimatedMinutes: p.estimatedMinutes,
})),
};
}
return {
id: "plan-active-123",
playerId: "player-1",
id: 'plan-active-123',
playerId: 'player-1',
targetDurationMinutes: 10,
estimatedProblemCount: totalProblems,
avgTimePerProblemSeconds: 40,
gameBreakSettings: { enabled: false, maxDurationMinutes: 5 },
parts,
summary,
masteredSkillIds: [
"basic.+1",
"basic.+2",
"basic.+3",
"fiveComplements.4=5-1",
],
status: "in_progress",
masteredSkillIds: ['basic.+1', 'basic.+2', 'basic.+3', 'fiveComplements.4=5-1'],
status: 'in_progress',
currentPartIndex: config.currentPartIndex ?? 0,
currentSlotIndex: config.currentSlotIndex ?? 0,
sessionHealth: config.sessionHealth ?? null,
@@ -238,20 +231,20 @@ function createMockSessionPlanWithProblems(config: {
pausedBy: null,
pauseReason: null,
retryState: null,
};
}
}
const defaultHandlers = {
onAnswer: async (result: Omit<SlotResult, "timestamp" | "partNumber">) => {
console.log("Answer recorded:", result);
onAnswer: async (result: Omit<SlotResult, 'timestamp' | 'partNumber'>) => {
console.log('Answer recorded:', result)
},
onEndEarly: (reason?: string) => {
alert(`Session ended early: ${reason || "No reason given"}`);
alert(`Session ended early: ${reason || 'No reason given'}`)
},
onPause: () => console.log("Session paused"),
onResume: () => console.log("Session resumed"),
onComplete: () => alert("Session completed!"),
};
onPause: () => console.log('Session paused'),
onResume: () => console.log('Session resumed'),
onComplete: () => alert('Session completed!'),
}
/**
* Wrapper for consistent styling
@@ -260,14 +253,14 @@ function SessionWrapper({ children }: { children: React.ReactNode }) {
return (
<div
className={css({
backgroundColor: "gray.100",
minHeight: "100vh",
padding: "1rem",
backgroundColor: 'gray.100',
minHeight: '100vh',
padding: '1rem',
})}
>
{children}
</div>
);
)
}
export const Part1Abacus: Story = {
@@ -275,117 +268,117 @@ export const Part1Abacus: Story = {
<SessionWrapper>
<ActiveSession
plan={createMockSessionPlanWithProblems({
skillLevel: "basic",
skillLevel: 'basic',
currentPartIndex: 0,
currentSlotIndex: 0,
})}
student={createMockStudent("Sonia")}
student={createMockStudent('Sonia')}
{...defaultHandlers}
/>
</SessionWrapper>
),
};
}
export const Part2Visualization: Story = {
render: () => (
<SessionWrapper>
<ActiveSession
plan={createMockSessionPlanWithProblems({
skillLevel: "fiveComplements",
skillLevel: 'fiveComplements',
currentPartIndex: 1,
currentSlotIndex: 0,
})}
student={createMockStudent("Marcus")}
student={createMockStudent('Marcus')}
{...defaultHandlers}
/>
</SessionWrapper>
),
};
}
export const Part3Linear: Story = {
render: () => (
<SessionWrapper>
<ActiveSession
plan={createMockSessionPlanWithProblems({
skillLevel: "tenComplements",
skillLevel: 'tenComplements',
currentPartIndex: 2,
currentSlotIndex: 0,
})}
student={createMockStudent("Luna")}
student={createMockStudent('Luna')}
{...defaultHandlers}
/>
</SessionWrapper>
),
};
}
export const WithHealthIndicator: Story = {
render: () => (
<SessionWrapper>
<ActiveSession
plan={createMockSessionPlanWithProblems({
skillLevel: "basic",
skillLevel: 'basic',
currentPartIndex: 0,
currentSlotIndex: 3,
sessionHealth: {
overall: "good",
overall: 'good',
accuracy: 0.85,
pacePercent: 110,
currentStreak: 4,
avgResponseTimeMs: 3500,
},
})}
student={createMockStudent("Sonia")}
student={createMockStudent('Sonia')}
{...defaultHandlers}
/>
</SessionWrapper>
),
};
}
export const Struggling: Story = {
render: () => (
<SessionWrapper>
<ActiveSession
plan={createMockSessionPlanWithProblems({
skillLevel: "tenComplements",
skillLevel: 'tenComplements',
currentPartIndex: 0,
currentSlotIndex: 5,
sessionHealth: {
overall: "struggling",
overall: 'struggling',
accuracy: 0.45,
pacePercent: 65,
currentStreak: -3,
avgResponseTimeMs: 8500,
},
})}
student={createMockStudent("Kai")}
student={createMockStudent('Kai')}
{...defaultHandlers}
/>
</SessionWrapper>
),
};
}
export const Warning: Story = {
render: () => (
<SessionWrapper>
<ActiveSession
plan={createMockSessionPlanWithProblems({
skillLevel: "fiveComplements",
skillLevel: 'fiveComplements',
currentPartIndex: 1,
currentSlotIndex: 2,
sessionHealth: {
overall: "warning",
overall: 'warning',
accuracy: 0.72,
pacePercent: 85,
currentStreak: -1,
avgResponseTimeMs: 5000,
},
})}
student={createMockStudent("Luna")}
student={createMockStudent('Luna')}
{...defaultHandlers}
/>
</SessionWrapper>
),
};
}
/**
* Interactive demo with simulated answering
@@ -394,20 +387,20 @@ function InteractiveSessionDemo() {
const [plan, setPlan] = useState(() =>
createMockSessionPlanWithProblems({
totalProblems: 6,
skillLevel: "basic",
skillLevel: 'basic',
sessionHealth: {
overall: "good",
overall: 'good',
accuracy: 1,
pacePercent: 100,
currentStreak: 0,
avgResponseTimeMs: 0,
},
}),
);
const [results, setResults] = useState<SlotResult[]>([]);
})
)
const [results, setResults] = useState<SlotResult[]>([])
const handleAnswer = useCallback(
async (result: Omit<SlotResult, "timestamp" | "partNumber">) => {
async (result: Omit<SlotResult, 'timestamp' | 'partNumber'>) => {
const fullResult: SlotResult = {
...result,
partNumber: (plan.currentPartIndex + 1) as 1 | 2 | 3,
@@ -415,14 +408,14 @@ function InteractiveSessionDemo() {
// Default help tracking fields if not provided
hadHelp: result.hadHelp ?? false,
incorrectAttempts: result.incorrectAttempts ?? 0,
helpTrigger: result.helpTrigger ?? "none",
};
setResults((prev) => [...prev, fullResult]);
helpTrigger: result.helpTrigger ?? 'none',
}
setResults((prev) => [...prev, fullResult])
// Advance to next problem
setPlan((prev) => {
const currentPart = prev.parts[prev.currentPartIndex];
const nextSlotIndex = prev.currentSlotIndex + 1;
const currentPart = prev.parts[prev.currentPartIndex]
const nextSlotIndex = prev.currentSlotIndex + 1
if (nextSlotIndex >= currentPart.slots.length) {
// Move to next part
@@ -430,40 +423,40 @@ function InteractiveSessionDemo() {
...prev,
currentPartIndex: prev.currentPartIndex + 1,
currentSlotIndex: 0,
};
}
}
return {
...prev,
currentSlotIndex: nextSlotIndex,
};
});
}
})
},
[plan.currentPartIndex],
);
[plan.currentPartIndex]
)
const handleComplete = useCallback(() => {
alert(
`Session complete! Results: ${results.filter((r) => r.isCorrect).length}/${results.length} correct`,
);
}, [results]);
`Session complete! Results: ${results.filter((r) => r.isCorrect).length}/${results.length} correct`
)
}, [results])
return (
<SessionWrapper>
<ActiveSession
plan={plan}
student={createMockStudent("Interactive Demo")}
student={createMockStudent('Interactive Demo')}
onAnswer={handleAnswer}
onEndEarly={(reason) => alert(`Ended: ${reason}`)}
onComplete={handleComplete}
/>
</SessionWrapper>
);
)
}
export const Interactive: Story = {
render: () => <InteractiveSessionDemo />,
};
}
export const MidSession: Story = {
render: () => (
@@ -471,20 +464,20 @@ export const MidSession: Story = {
<ActiveSession
plan={createMockSessionPlanWithProblems({
totalProblems: 15,
skillLevel: "fiveComplements",
skillLevel: 'fiveComplements',
currentPartIndex: 0,
currentSlotIndex: 4,
sessionHealth: {
overall: "good",
overall: 'good',
accuracy: 0.8,
pacePercent: 95,
currentStreak: 2,
avgResponseTimeMs: 4200,
},
})}
student={createMockStudent("Sonia")}
student={createMockStudent('Sonia')}
{...defaultHandlers}
/>
</SessionWrapper>
),
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,131 +1,125 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import type { Meta, StoryObj } from '@storybook/react'
import { ThemeProvider } from '@/contexts/ThemeContext'
import type {
ProblemSlot,
SessionPart,
SessionPlan,
SessionSummary,
SlotResult,
} from "@/db/schema/session-plans";
import { css } from "../../../styled-system/css";
import { type PauseInfo, SessionPausedModal } from "./SessionPausedModal";
} from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import { type PauseInfo, SessionPausedModal } from './SessionPausedModal'
const meta: Meta<typeof SessionPausedModal> = {
title: "Practice/SessionPausedModal",
title: 'Practice/SessionPausedModal',
component: SessionPausedModal,
parameters: {
layout: "fullscreen",
layout: 'fullscreen',
},
tags: ["autodocs"],
};
tags: ['autodocs'],
}
export default meta;
type Story = StoryObj<typeof SessionPausedModal>;
export default meta
type Story = StoryObj<typeof SessionPausedModal>
/**
* Create mock slots for a session part
*/
function createMockSlots(
count: number,
purpose: ProblemSlot["purpose"],
): ProblemSlot[] {
function createMockSlots(count: number, purpose: ProblemSlot['purpose']): ProblemSlot[] {
return Array.from({ length: count }, (_, i) => ({
index: i,
purpose,
constraints: {},
}));
}))
}
/**
* Create a mock session plan at various stages of progress
*/
function createMockSessionPlan(config: {
currentPartIndex: number;
currentSlotIndex: number;
completedCount: number;
currentPartIndex: number
currentSlotIndex: number
completedCount: number
}): SessionPlan {
const { currentPartIndex, currentSlotIndex, completedCount } = config;
const { currentPartIndex, currentSlotIndex, completedCount } = config
const parts: SessionPart[] = [
{
partNumber: 1,
type: "abacus",
format: "vertical",
type: 'abacus',
format: 'vertical',
useAbacus: true,
slots: createMockSlots(5, "focus"),
slots: createMockSlots(5, 'focus'),
estimatedMinutes: 5,
},
{
partNumber: 2,
type: "visualization",
format: "vertical",
type: 'visualization',
format: 'vertical',
useAbacus: false,
slots: createMockSlots(5, "reinforce"),
slots: createMockSlots(5, 'reinforce'),
estimatedMinutes: 4,
},
{
partNumber: 3,
type: "linear",
format: "linear",
type: 'linear',
format: 'linear',
useAbacus: false,
slots: createMockSlots(5, "review"),
slots: createMockSlots(5, 'review'),
estimatedMinutes: 3,
},
];
]
const summary: SessionSummary = {
focusDescription: "Basic Addition",
focusDescription: 'Basic Addition',
totalProblemCount: 15,
estimatedMinutes: 12,
parts: parts.map((p) => ({
partNumber: p.partNumber,
type: p.type,
description:
p.type === "abacus"
? "Use Abacus"
: p.type === "visualization"
? "Mental Math (Visualization)"
: "Mental Math (Linear)",
p.type === 'abacus'
? 'Use Abacus'
: p.type === 'visualization'
? 'Mental Math (Visualization)'
: 'Mental Math (Linear)',
problemCount: p.slots.length,
estimatedMinutes: p.estimatedMinutes,
})),
};
}
// Generate mock results for completed problems
const results: SlotResult[] = Array.from(
{ length: completedCount },
(_, i) => ({
partNumber: (i < 5 ? 1 : i < 10 ? 2 : 3) as 1 | 2 | 3,
slotIndex: i % 5,
problem: {
terms: [3, 4, 2],
answer: 9,
skillsRequired: ["basic.directAddition"],
},
studentAnswer: 9,
isCorrect: true,
responseTimeMs: 3000 + Math.random() * 2000,
skillsExercised: ["basic.directAddition"],
usedOnScreenAbacus: i < 5,
timestamp: new Date(Date.now() - (completedCount - i) * 30000),
hadHelp: false,
incorrectAttempts: 0,
}),
);
const results: SlotResult[] = Array.from({ length: completedCount }, (_, i) => ({
partNumber: (i < 5 ? 1 : i < 10 ? 2 : 3) as 1 | 2 | 3,
slotIndex: i % 5,
problem: {
terms: [3, 4, 2],
answer: 9,
skillsRequired: ['basic.directAddition'],
},
studentAnswer: 9,
isCorrect: true,
responseTimeMs: 3000 + Math.random() * 2000,
skillsExercised: ['basic.directAddition'],
usedOnScreenAbacus: i < 5,
timestamp: new Date(Date.now() - (completedCount - i) * 30000),
hadHelp: false,
incorrectAttempts: 0,
}))
return {
id: "plan-123",
playerId: "player-1",
id: 'plan-123',
playerId: 'player-1',
targetDurationMinutes: 12,
estimatedProblemCount: 15,
avgTimePerProblemSeconds: 45,
parts,
summary,
status: "in_progress",
status: 'in_progress',
currentPartIndex,
currentSlotIndex,
sessionHealth: {
overall: "good",
overall: 'good',
accuracy: 0.85,
pacePercent: 100,
currentStreak: 3,
@@ -143,19 +137,20 @@ function createMockSessionPlan(config: {
pausedBy: null,
pauseReason: null,
retryState: null,
};
gameBreakSettings: null,
}
}
const mockStudent = {
name: "Sonia",
emoji: "🦄",
color: "#E879F9",
};
name: 'Sonia',
emoji: '🦄',
color: '#E879F9',
}
const handlers = {
onResume: () => alert("Resume clicked!"),
onEndSession: () => alert("End Session clicked!"),
};
onResume: () => alert('Resume clicked!'),
onEndSession: () => alert('End Session clicked!'),
}
/**
* Wrapper for consistent styling
@@ -165,15 +160,15 @@ function ModalWrapper({ children }: { children: React.ReactNode }) {
<ThemeProvider>
<div
className={css({
minHeight: "100vh",
backgroundColor: "gray.100",
padding: "2rem",
minHeight: '100vh',
backgroundColor: 'gray.100',
padding: '2rem',
})}
>
{children}
</div>
</ThemeProvider>
);
)
}
// =============================================================================
@@ -184,8 +179,8 @@ export const ManualPause: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 30 * 1000), // 30 seconds ago
reason: "manual",
};
reason: 'manual',
}
return (
<ModalWrapper>
<SessionPausedModal
@@ -200,16 +195,16 @@ export const ManualPause: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
export const ManualPauseLong: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 2 * 60 * 1000), // 2 minutes ago
reason: "manual",
};
reason: 'manual',
}
return (
<ModalWrapper>
<SessionPausedModal
@@ -224,9 +219,9 @@ export const ManualPauseLong: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
// =============================================================================
// Auto-Pause with Statistics Stories
@@ -236,7 +231,7 @@ export const AutoPauseWithStatistics: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 15 * 1000), // 15 seconds ago
reason: "auto-timeout",
reason: 'auto-timeout',
autoPauseStats: {
meanMs: 4200,
stdDevMs: 1800,
@@ -244,7 +239,7 @@ export const AutoPauseWithStatistics: Story = {
sampleCount: 8,
usedStatistics: true,
},
};
}
return (
<ModalWrapper>
<SessionPausedModal
@@ -259,15 +254,15 @@ export const AutoPauseWithStatistics: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
export const AutoPauseHighVariance: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 45 * 1000), // 45 seconds ago
reason: "auto-timeout",
reason: 'auto-timeout',
autoPauseStats: {
meanMs: 5500,
stdDevMs: 4200, // High variance
@@ -275,12 +270,12 @@ export const AutoPauseHighVariance: Story = {
sampleCount: 12,
usedStatistics: true,
},
};
}
return (
<ModalWrapper>
<SessionPausedModal
isOpen={true}
student={{ name: "Marcus", emoji: "🚀", color: "#60A5FA" }}
student={{ name: 'Marcus', emoji: '🚀', color: '#60A5FA' }}
session={createMockSessionPlan({
currentPartIndex: 2,
currentSlotIndex: 2,
@@ -290,15 +285,15 @@ export const AutoPauseHighVariance: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
export const AutoPauseFastStudent: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 10 * 1000), // 10 seconds ago
reason: "auto-timeout",
reason: 'auto-timeout',
autoPauseStats: {
meanMs: 2100, // Very fast student
stdDevMs: 600, // Consistent
@@ -306,12 +301,12 @@ export const AutoPauseFastStudent: Story = {
sampleCount: 15,
usedStatistics: true,
},
};
}
return (
<ModalWrapper>
<SessionPausedModal
isOpen={true}
student={{ name: "Luna", emoji: "⚡", color: "#FBBF24" }}
student={{ name: 'Luna', emoji: '⚡', color: '#FBBF24' }}
session={createMockSessionPlan({
currentPartIndex: 2,
currentSlotIndex: 4,
@@ -321,9 +316,9 @@ export const AutoPauseFastStudent: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
// =============================================================================
// Auto-Pause without Statistics (Default Timeout)
@@ -333,7 +328,7 @@ export const AutoPauseDefaultTimeout: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 20 * 1000), // 20 seconds ago
reason: "auto-timeout",
reason: 'auto-timeout',
autoPauseStats: {
meanMs: 3500,
stdDevMs: 1500,
@@ -341,7 +336,7 @@ export const AutoPauseDefaultTimeout: Story = {
sampleCount: 3, // Not enough for statistics
usedStatistics: false,
},
};
}
return (
<ModalWrapper>
<SessionPausedModal
@@ -356,15 +351,15 @@ export const AutoPauseDefaultTimeout: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
export const AutoPauseNeedsTwoMoreProblems: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 5 * 1000), // 5 seconds ago
reason: "auto-timeout",
reason: 'auto-timeout',
autoPauseStats: {
meanMs: 4000,
stdDevMs: 2000,
@@ -372,12 +367,12 @@ export const AutoPauseNeedsTwoMoreProblems: Story = {
sampleCount: 3, // Need 5-3=2 more
usedStatistics: false,
},
};
}
return (
<ModalWrapper>
<SessionPausedModal
isOpen={true}
student={{ name: "Kai", emoji: "🌟", color: "#34D399" }}
student={{ name: 'Kai', emoji: '🌟', color: '#34D399' }}
session={createMockSessionPlan({
currentPartIndex: 0,
currentSlotIndex: 3,
@@ -387,15 +382,15 @@ export const AutoPauseNeedsTwoMoreProblems: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
export const AutoPauseNeedsOneMoreProblem: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 8 * 1000), // 8 seconds ago
reason: "auto-timeout",
reason: 'auto-timeout',
autoPauseStats: {
meanMs: 3200,
stdDevMs: 1100,
@@ -403,12 +398,12 @@ export const AutoPauseNeedsOneMoreProblem: Story = {
sampleCount: 4, // Need 5-4=1 more
usedStatistics: false,
},
};
}
return (
<ModalWrapper>
<SessionPausedModal
isOpen={true}
student={{ name: "Nova", emoji: "✨", color: "#F472B6" }}
student={{ name: 'Nova', emoji: '✨', color: '#F472B6' }}
session={createMockSessionPlan({
currentPartIndex: 0,
currentSlotIndex: 4,
@@ -418,9 +413,9 @@ export const AutoPauseNeedsOneMoreProblem: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
// =============================================================================
// Progress State Stories
@@ -430,8 +425,8 @@ export const EarlyInSession: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 15 * 1000),
reason: "manual",
};
reason: 'manual',
}
return (
<ModalWrapper>
<SessionPausedModal
@@ -446,16 +441,16 @@ export const EarlyInSession: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
export const MidSession: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 30 * 1000),
reason: "manual",
};
reason: 'manual',
}
return (
<ModalWrapper>
<SessionPausedModal
@@ -470,16 +465,16 @@ export const MidSession: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
export const NearEnd: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 20 * 1000),
reason: "manual",
};
reason: 'manual',
}
return (
<ModalWrapper>
<SessionPausedModal
@@ -494,9 +489,9 @@ export const NearEnd: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
// =============================================================================
// Different Part Types
@@ -506,7 +501,7 @@ export const InAbacusPart: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 25 * 1000),
reason: "auto-timeout",
reason: 'auto-timeout',
autoPauseStats: {
meanMs: 5000,
stdDevMs: 2000,
@@ -514,12 +509,12 @@ export const InAbacusPart: Story = {
sampleCount: 6,
usedStatistics: true,
},
};
}
return (
<ModalWrapper>
<SessionPausedModal
isOpen={true}
student={{ name: "Alex", emoji: "🧮", color: "#818CF8" }}
student={{ name: 'Alex', emoji: '🧮', color: '#818CF8' }}
session={createMockSessionPlan({
currentPartIndex: 0,
currentSlotIndex: 2,
@@ -529,15 +524,15 @@ export const InAbacusPart: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
export const InVisualizationPart: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 40 * 1000),
reason: "auto-timeout",
reason: 'auto-timeout',
autoPauseStats: {
meanMs: 4500,
stdDevMs: 1500,
@@ -545,12 +540,12 @@ export const InVisualizationPart: Story = {
sampleCount: 8,
usedStatistics: true,
},
};
}
return (
<ModalWrapper>
<SessionPausedModal
isOpen={true}
student={{ name: "Maya", emoji: "🧠", color: "#FB923C" }}
student={{ name: 'Maya', emoji: '🧠', color: '#FB923C' }}
session={createMockSessionPlan({
currentPartIndex: 1,
currentSlotIndex: 3,
@@ -560,15 +555,15 @@ export const InVisualizationPart: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
export const InLinearPart: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 55 * 1000),
reason: "auto-timeout",
reason: 'auto-timeout',
autoPauseStats: {
meanMs: 3200,
stdDevMs: 900,
@@ -576,12 +571,12 @@ export const InLinearPart: Story = {
sampleCount: 11,
usedStatistics: true,
},
};
}
return (
<ModalWrapper>
<SessionPausedModal
isOpen={true}
student={{ name: "River", emoji: "💭", color: "#2DD4BF" }}
student={{ name: 'River', emoji: '💭', color: '#2DD4BF' }}
session={createMockSessionPlan({
currentPartIndex: 2,
currentSlotIndex: 1,
@@ -591,9 +586,9 @@ export const InLinearPart: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
// =============================================================================
// Long Pause Durations
@@ -603,8 +598,8 @@ export const LongPause: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 5 * 60 * 1000), // 5 minutes ago
reason: "manual",
};
reason: 'manual',
}
return (
<ModalWrapper>
<SessionPausedModal
@@ -619,16 +614,16 @@ export const LongPause: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
export const VeryLongPause: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 25 * 60 * 1000), // 25 minutes ago
reason: "manual",
};
reason: 'manual',
}
return (
<ModalWrapper>
<SessionPausedModal
@@ -643,21 +638,21 @@ export const VeryLongPause: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
export const HourLongPause: Story = {
render: () => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(Date.now() - 72 * 60 * 1000), // 1h 12m ago
reason: "manual",
};
reason: 'manual',
}
return (
<ModalWrapper>
<SessionPausedModal
isOpen={true}
student={{ name: "Sleepy", emoji: "😴", color: "#94A3B8" }}
student={{ name: 'Sleepy', emoji: '😴', color: '#94A3B8' }}
session={createMockSessionPlan({
currentPartIndex: 1,
currentSlotIndex: 1,
@@ -667,9 +662,9 @@ export const HourLongPause: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
// =============================================================================
// Legacy (No Pause Info)
@@ -690,9 +685,9 @@ export const NoPauseInfo: Story = {
{...handlers}
/>
</ModalWrapper>
);
)
},
};
}
// =============================================================================
// All Cases Comparison
@@ -702,12 +697,12 @@ export const AllPauseTypes: Story = {
render: () => {
const manualPause: PauseInfo = {
pausedAt: new Date(Date.now() - 30 * 1000),
reason: "manual",
};
reason: 'manual',
}
const autoWithStats: PauseInfo = {
pausedAt: new Date(Date.now() - 15 * 1000),
reason: "auto-timeout",
reason: 'auto-timeout',
autoPauseStats: {
meanMs: 4200,
stdDevMs: 1800,
@@ -715,11 +710,11 @@ export const AllPauseTypes: Story = {
sampleCount: 8,
usedStatistics: true,
},
};
}
const autoWithoutStats: PauseInfo = {
pausedAt: new Date(Date.now() - 20 * 1000),
reason: "auto-timeout",
reason: 'auto-timeout',
autoPauseStats: {
meanMs: 3500,
stdDevMs: 1500,
@@ -727,23 +722,23 @@ export const AllPauseTypes: Story = {
sampleCount: 3,
usedStatistics: false,
},
};
}
return (
<div
className={css({
display: "flex",
flexDirection: "column",
gap: "2rem",
display: 'flex',
flexDirection: 'column',
gap: '2rem',
})}
>
<div>
<h3
className={css({
fontSize: "1.25rem",
fontWeight: "bold",
marginBottom: "1rem",
padding: "0 2rem",
fontSize: '1.25rem',
fontWeight: 'bold',
marginBottom: '1rem',
padding: '0 2rem',
})}
>
Manual Pause
@@ -751,7 +746,7 @@ export const AllPauseTypes: Story = {
<ModalWrapper>
<SessionPausedModal
isOpen={true}
student={{ name: "Manual", emoji: "✋", color: "#60A5FA" }}
student={{ name: 'Manual', emoji: '✋', color: '#60A5FA' }}
session={createMockSessionPlan({
currentPartIndex: 1,
currentSlotIndex: 2,
@@ -766,10 +761,10 @@ export const AllPauseTypes: Story = {
<div>
<h3
className={css({
fontSize: "1.25rem",
fontWeight: "bold",
marginBottom: "1rem",
padding: "0 2rem",
fontSize: '1.25rem',
fontWeight: 'bold',
marginBottom: '1rem',
padding: '0 2rem',
})}
>
Auto-Pause with Statistics
@@ -777,7 +772,7 @@ export const AllPauseTypes: Story = {
<ModalWrapper>
<SessionPausedModal
isOpen={true}
student={{ name: "Stats", emoji: "📊", color: "#34D399" }}
student={{ name: 'Stats', emoji: '📊', color: '#34D399' }}
session={createMockSessionPlan({
currentPartIndex: 1,
currentSlotIndex: 3,
@@ -792,10 +787,10 @@ export const AllPauseTypes: Story = {
<div>
<h3
className={css({
fontSize: "1.25rem",
fontWeight: "bold",
marginBottom: "1rem",
padding: "0 2rem",
fontSize: '1.25rem',
fontWeight: 'bold',
marginBottom: '1rem',
padding: '0 2rem',
})}
>
Auto-Pause (Default Timeout)
@@ -803,7 +798,7 @@ export const AllPauseTypes: Story = {
<ModalWrapper>
<SessionPausedModal
isOpen={true}
student={{ name: "Default", emoji: "⏱️", color: "#FBBF24" }}
student={{ name: 'Default', emoji: '⏱️', color: '#FBBF24' }}
session={createMockSessionPlan({
currentPartIndex: 0,
currentSlotIndex: 3,
@@ -815,6 +810,6 @@ export const AllPauseTypes: Story = {
</ModalWrapper>
</div>
</div>
);
)
},
};
}

View File

@@ -1,13 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "@/contexts/ThemeContext";
import type { Meta, StoryObj } from '@storybook/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeProvider } from '@/contexts/ThemeContext'
import type {
MaintenanceMode,
ProgressionMode,
RemediationMode,
} from "@/lib/curriculum/session-mode";
import { StartPracticeModal } from "./StartPracticeModal";
import { css } from "../../../styled-system/css";
ProgressionMode as ProgressionModeType,
RemediationMode as RemediationModeType,
} from '@/lib/curriculum/session-mode'
import { StartPracticeModal } from './StartPracticeModal'
import { css } from '../../../styled-system/css'
// Create a fresh query client for each story
function createQueryClient() {
@@ -18,47 +18,47 @@ function createQueryClient() {
staleTime: Infinity,
},
},
});
})
}
// Mock router
const mockRouter = {
push: (url: string) => console.log("Router push:", url),
refresh: () => console.log("Router refresh"),
};
push: (url: string) => console.log('Router push:', url),
refresh: () => console.log('Router refresh'),
}
// Story wrapper with providers
function StoryWrapper({
children,
theme = "light",
theme = 'light',
}: {
children: React.ReactNode;
theme?: "light" | "dark";
children: React.ReactNode
theme?: 'light' | 'dark'
}) {
const queryClient = createQueryClient();
const queryClient = createQueryClient()
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<div
className={css({
minHeight: "100vh",
padding: "2rem",
backgroundColor: theme === "dark" ? "#1a1a2e" : "#f5f5f5",
minHeight: '100vh',
padding: '2rem',
backgroundColor: theme === 'dark' ? '#1a1a2e' : '#f5f5f5',
})}
>
{children}
</div>
</ThemeProvider>
</QueryClientProvider>
);
)
}
const meta: Meta<typeof StartPracticeModal> = {
title: "Practice/StartPracticeModal",
title: 'Practice/StartPracticeModal',
component: StartPracticeModal,
parameters: {
layout: "fullscreen",
layout: 'fullscreen',
nextjs: {
appDirectory: true,
navigation: {
@@ -66,78 +66,78 @@ const meta: Meta<typeof StartPracticeModal> = {
},
},
},
tags: ["autodocs"],
};
tags: ['autodocs'],
}
export default meta;
type Story = StoryObj<typeof StartPracticeModal>;
export default meta
type Story = StoryObj<typeof StartPracticeModal>
// Mock session modes for stories
const mockMaintenanceMode: MaintenanceMode = {
type: "maintenance",
focusDescription: "Mixed practice",
type: 'maintenance',
focusDescription: 'Mixed practice',
skillCount: 8,
};
}
const mockProgressionMode: ProgressionMode = {
type: "progression",
nextSkill: { skillId: "add-5", displayName: "+5", pKnown: 0 },
const mockProgressionMode: ProgressionModeType = {
type: 'progression',
nextSkill: { skillId: 'add-5', displayName: '+5', pKnown: 0 },
phase: {
id: "L1.add.+5.direct",
id: 'L1.add.+5.direct',
levelId: 1,
operation: "addition",
operation: 'addition',
targetNumber: 5,
usesFiveComplement: false,
usesTenComplement: false,
name: "Direct Addition 5",
description: "Learn to add 5 using direct technique",
primarySkillId: "add-5",
name: 'Direct Addition 5',
description: 'Learn to add 5 using direct technique',
primarySkillId: 'add-5',
order: 3,
},
tutorialRequired: true,
skipCount: 0,
focusDescription: "Learning: +5",
};
focusDescription: 'Learning: +5',
}
const mockRemediationMode: RemediationMode = {
type: "remediation",
const mockRemediationMode: RemediationModeType = {
type: 'remediation',
weakSkills: [
{ skillId: "add-3", displayName: "+3", pKnown: 0.35 },
{ skillId: "add-4", displayName: "+4", pKnown: 0.42 },
{ skillId: 'add-3', displayName: '+3', pKnown: 0.35 },
{ skillId: 'add-4', displayName: '+4', pKnown: 0.42 },
],
focusDescription: "Strengthening: +3 and +4",
};
focusDescription: 'Strengthening: +3 and +4',
}
const mockRemediationModeSingleSkill: RemediationMode = {
type: "remediation",
weakSkills: [{ skillId: "add-2", displayName: "+2", pKnown: 0.28 }],
focusDescription: "Strengthening: +2",
};
const mockRemediationModeSingleSkill: RemediationModeType = {
type: 'remediation',
weakSkills: [{ skillId: 'add-2', displayName: '+2', pKnown: 0.28 }],
focusDescription: 'Strengthening: +2',
}
const mockRemediationModeManySkills: RemediationMode = {
type: "remediation",
const mockRemediationModeManySkills: RemediationModeType = {
type: 'remediation',
weakSkills: [
{ skillId: "add-1", displayName: "+1", pKnown: 0.31 },
{ skillId: "add-2", displayName: "+2", pKnown: 0.38 },
{ skillId: "add-3", displayName: "+3", pKnown: 0.25 },
{ skillId: "add-4", displayName: "+4", pKnown: 0.42 },
{ skillId: "sub-1", displayName: "-1", pKnown: 0.33 },
{ skillId: "sub-2", displayName: "-2", pKnown: 0.29 },
{ skillId: 'add-1', displayName: '+1', pKnown: 0.31 },
{ skillId: 'add-2', displayName: '+2', pKnown: 0.38 },
{ skillId: 'add-3', displayName: '+3', pKnown: 0.25 },
{ skillId: 'add-4', displayName: '+4', pKnown: 0.42 },
{ skillId: 'sub-1', displayName: '-1', pKnown: 0.33 },
{ skillId: 'sub-2', displayName: '-2', pKnown: 0.29 },
],
focusDescription: "Strengthening: +1, +2, +3, +4, -1, -2",
};
focusDescription: 'Strengthening: +1, +2, +3, +4, -1, -2',
}
// Default props
const defaultProps = {
studentId: "test-student-1",
studentName: "Sonia",
focusDescription: "Mixed practice",
studentId: 'test-student-1',
studentName: 'Sonia',
focusDescription: 'Mixed practice',
sessionMode: mockMaintenanceMode,
secondsPerTerm: 4,
onClose: () => console.log("Modal closed"),
onStarted: () => console.log("Practice started"),
onClose: () => console.log('Modal closed'),
onStarted: () => console.log('Practice started'),
open: true,
};
}
/**
* Default state - no existing plan, no new skill ready
@@ -148,7 +148,7 @@ export const Default: Story = {
<StartPracticeModal {...defaultProps} />
</StoryWrapper>
),
};
}
/**
* With an existing plan that can be resumed
@@ -159,20 +159,20 @@ export const WithExistingPlan: Story = {
<StartPracticeModal
{...defaultProps}
existingPlan={{
id: "plan-123",
playerId: "test-student-1",
id: 'plan-123',
playerId: 'test-student-1',
targetDurationMinutes: 10,
estimatedProblemCount: 15,
avgTimePerProblemSeconds: 40,
parts: [],
summary: {
focusDescription: "Five Complements",
focusDescription: 'Five Complements',
totalProblemCount: 15,
estimatedMinutes: 10,
parts: [],
},
masteredSkillIds: [],
status: "approved",
status: 'approved',
currentPartIndex: 0,
currentSlotIndex: 0,
sessionHealth: null,
@@ -187,11 +187,12 @@ export const WithExistingPlan: Story = {
pausedBy: null,
pauseReason: null,
retryState: null,
gameBreakSettings: { enabled: true, maxDurationMinutes: 5 },
}}
/>
</StoryWrapper>
),
};
}
/**
* Dark theme variant
@@ -204,7 +205,7 @@ export const DarkTheme: Story = {
</div>
</StoryWrapper>
),
};
}
/**
* Remediation mode - student has weak skills to strengthen (2 skills)
@@ -220,7 +221,7 @@ export const RemediationMode: Story = {
/>
</StoryWrapper>
),
};
}
/**
* Remediation mode with a single weak skill
@@ -236,7 +237,7 @@ export const RemediationModeSingleSkill: Story = {
/>
</StoryWrapper>
),
};
}
/**
* Remediation mode with many weak skills (shows overflow)
@@ -252,7 +253,7 @@ export const RemediationModeManySkills: Story = {
/>
</StoryWrapper>
),
};
}
/**
* Remediation mode - dark theme
@@ -270,7 +271,7 @@ export const RemediationModeDark: Story = {
</div>
</StoryWrapper>
),
};
}
/**
* Progression mode - student is ready to learn a new skill
@@ -286,7 +287,7 @@ export const ProgressionMode: Story = {
/>
</StoryWrapper>
),
};
}
/**
* Documentation note about the SessionMode system
@@ -296,30 +297,30 @@ export const DocumentationNote: Story = {
<StoryWrapper>
<div
className={css({
padding: "2rem",
backgroundColor: "white",
borderRadius: "12px",
maxWidth: "600px",
margin: "0 auto",
padding: '2rem',
backgroundColor: 'white',
borderRadius: '12px',
maxWidth: '600px',
margin: '0 auto',
})}
>
<h2
className={css({
fontSize: "1.25rem",
fontWeight: "bold",
marginBottom: "1rem",
fontSize: '1.25rem',
fontWeight: 'bold',
marginBottom: '1rem',
})}
>
Session Mode System
</h2>
<p className={css({ marginBottom: "1rem", lineHeight: 1.6 })}>
The StartPracticeModal receives a <strong>sessionMode</strong> prop
that determines the type of session:
<p className={css({ marginBottom: '1rem', lineHeight: 1.6 })}>
The StartPracticeModal receives a <strong>sessionMode</strong> prop that determines the
type of session:
</p>
<ul
className={css({
paddingLeft: "1.5rem",
marginBottom: "1rem",
paddingLeft: '1.5rem',
marginBottom: '1rem',
lineHeight: 1.8,
})}
>
@@ -327,26 +328,23 @@ export const DocumentationNote: Story = {
<strong>Maintenance:</strong> All skills are strong, mixed practice
</li>
<li>
<strong>Remediation:</strong> Weak skills need strengthening (shown
in targeting info)
<strong>Remediation:</strong> Weak skills need strengthening (shown in targeting info)
</li>
<li>
<strong>Progression:</strong> Ready to learn new skill, may include
tutorial gate
<strong>Progression:</strong> Ready to learn new skill, may include tutorial gate
</li>
</ul>
<p
className={css({
fontSize: "0.875rem",
color: "gray.600",
fontStyle: "italic",
fontSize: '0.875rem',
color: 'gray.600',
fontStyle: 'italic',
})}
>
The sessionMode is fetched via useSessionMode() hook and passed to the
modal. See SessionModeBanner stories for the dashboard banner
component.
The sessionMode is fetched via useSessionMode() hook and passed to the modal. See
SessionModeBanner stories for the dashboard banner component.
</p>
</div>
</StoryWrapper>
),
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
import { renderHook, act, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { useGameBreakTimer } from '../useGameBreakTimer'
describe('useGameBreakTimer', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should initialize with inactive state', () => {
const onTimeout = vi.fn()
const { result } = renderHook(() =>
useGameBreakTimer({
maxDurationMinutes: 5,
onTimeout,
})
)
expect(result.current.isActive).toBe(false)
expect(result.current.startTime).toBeNull()
expect(result.current.elapsedMs).toBe(0)
expect(result.current.remainingMs).toBe(5 * 60 * 1000)
expect(result.current.remainingMinutes).toBe(5)
expect(result.current.remainingSeconds).toBe(0)
expect(result.current.percentRemaining).toBe(100)
})
it('should start timer when start() is called', () => {
const onTimeout = vi.fn()
const { result } = renderHook(() =>
useGameBreakTimer({
maxDurationMinutes: 5,
onTimeout,
})
)
act(() => {
result.current.start()
})
expect(result.current.isActive).toBe(true)
expect(result.current.startTime).not.toBeNull()
})
it('should not start when disabled', () => {
const onTimeout = vi.fn()
const { result } = renderHook(() =>
useGameBreakTimer({
maxDurationMinutes: 5,
onTimeout,
enabled: false,
})
)
act(() => {
result.current.start()
})
expect(result.current.isActive).toBe(false)
expect(result.current.startTime).toBeNull()
})
it('should stop timer when stop() is called', () => {
const onTimeout = vi.fn()
const { result } = renderHook(() =>
useGameBreakTimer({
maxDurationMinutes: 5,
onTimeout,
})
)
act(() => {
result.current.start()
})
expect(result.current.isActive).toBe(true)
act(() => {
result.current.stop()
})
expect(result.current.isActive).toBe(false)
expect(result.current.startTime).toBeNull()
})
it('should reset timer when reset() is called', () => {
const onTimeout = vi.fn()
const { result } = renderHook(() =>
useGameBreakTimer({
maxDurationMinutes: 5,
onTimeout,
})
)
act(() => {
result.current.start()
})
act(() => {
result.current.reset()
})
expect(result.current.isActive).toBe(false)
expect(result.current.startTime).toBeNull()
expect(result.current.elapsedMs).toBe(0)
})
it('should calculate remaining time correctly', () => {
const onTimeout = vi.fn()
const { result } = renderHook(() =>
useGameBreakTimer({
maxDurationMinutes: 2,
onTimeout,
})
)
// Max duration: 2 minutes = 120,000 ms
expect(result.current.remainingMs).toBe(120_000)
expect(result.current.remainingMinutes).toBe(2)
expect(result.current.remainingSeconds).toBe(0)
expect(result.current.percentRemaining).toBe(100)
})
it('should calculate percent remaining correctly for different durations', () => {
const onTimeout = vi.fn()
// Test with 10 minute duration
const { result: result10 } = renderHook(() =>
useGameBreakTimer({
maxDurationMinutes: 10,
onTimeout,
})
)
expect(result10.current.remainingMs).toBe(600_000)
expect(result10.current.percentRemaining).toBe(100)
// Test with 3 minute duration
const { result: result3 } = renderHook(() =>
useGameBreakTimer({
maxDurationMinutes: 3,
onTimeout,
})
)
expect(result3.current.remainingMs).toBe(180_000)
expect(result3.current.percentRemaining).toBe(100)
})
it('should handle zero duration edge case', () => {
const onTimeout = vi.fn()
const { result } = renderHook(() =>
useGameBreakTimer({
maxDurationMinutes: 0,
onTimeout,
})
)
expect(result.current.remainingMs).toBe(0)
expect(result.current.percentRemaining).toBe(100) // Division by zero guard
})
it('should default enabled to true', () => {
const onTimeout = vi.fn()
const { result } = renderHook(() =>
useGameBreakTimer({
maxDurationMinutes: 5,
onTimeout,
// Not passing enabled - should default to true
})
)
act(() => {
result.current.start()
})
expect(result.current.isActive).toBe(true)
})
})

View File

@@ -0,0 +1,107 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
export interface UseGameBreakTimerOptions {
maxDurationMinutes: number
onTimeout: () => void
enabled?: boolean
}
export interface UseGameBreakTimerResult {
startTime: number | null
elapsedMs: number
remainingMs: number
remainingMinutes: number
remainingSeconds: number
percentRemaining: number
isActive: boolean
start: () => void
stop: () => void
reset: () => void
}
export function useGameBreakTimer({
maxDurationMinutes,
onTimeout,
enabled = true,
}: UseGameBreakTimerOptions): UseGameBreakTimerResult {
const [startTime, setStartTime] = useState<number | null>(null)
const [elapsedMs, setElapsedMs] = useState(0)
const animationFrameRef = useRef<number | null>(null)
const hasTimedOutRef = useRef(false)
const maxDurationMs = maxDurationMinutes * 60 * 1000
const start = useCallback(() => {
if (!enabled) return
hasTimedOutRef.current = false
setStartTime(Date.now())
setElapsedMs(0)
}, [enabled])
const stop = useCallback(() => {
setStartTime(null)
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
}, [])
const reset = useCallback(() => {
hasTimedOutRef.current = false
setStartTime(null)
setElapsedMs(0)
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
}, [])
useEffect(() => {
if (!startTime || !enabled) {
return
}
const updateTimer = () => {
const now = Date.now()
const elapsed = now - startTime
setElapsedMs(elapsed)
if (elapsed >= maxDurationMs) {
if (!hasTimedOutRef.current) {
hasTimedOutRef.current = true
onTimeout()
}
} else {
animationFrameRef.current = requestAnimationFrame(updateTimer)
}
}
animationFrameRef.current = requestAnimationFrame(updateTimer)
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [startTime, maxDurationMs, onTimeout, enabled])
const remainingMs = Math.max(0, maxDurationMs - elapsedMs)
const remainingMinutes = Math.floor(remainingMs / 60000)
const remainingSeconds = Math.floor((remainingMs % 60000) / 1000)
const percentRemaining = maxDurationMs > 0 ? (remainingMs / maxDurationMs) * 100 : 100
return {
startTime,
elapsedMs,
remainingMs,
remainingMinutes,
remainingSeconds,
percentRemaining,
isActive: startTime !== null && !hasTimedOutRef.current,
start,
stop,
reset,
}
}

View File

@@ -1,31 +1,24 @@
"use client";
'use client'
import {
useMutation,
useQuery,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
import type { SessionPlan, SlotResult } from "@/db/schema/session-plans";
import type { ProblemGenerationMode } from "@/lib/curriculum/config";
import type { SessionMode } from "@/lib/curriculum/session-mode";
import { api } from "@/lib/queryClient";
import { sessionPlanKeys } from "@/lib/queryKeys";
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import type { SessionPlan, SlotResult, GameBreakSettings } from '@/db/schema/session-plans'
import type { ProblemGenerationMode } from '@/lib/curriculum/config'
import type { SessionMode } from '@/lib/curriculum/session-mode'
import { api } from '@/lib/queryClient'
import { sessionPlanKeys } from '@/lib/queryKeys'
// Re-export query keys for consumers
export { sessionPlanKeys } from "@/lib/queryKeys";
export { sessionPlanKeys } from '@/lib/queryKeys'
// ============================================================================
// API Functions
// ============================================================================
async function fetchActiveSessionPlan(
playerId: string,
): Promise<SessionPlan | null> {
const res = await api(`curriculum/${playerId}/sessions/plans`);
if (!res.ok) throw new Error("Failed to fetch active session plan");
const data = await res.json();
return data.plan ?? null;
async function fetchActiveSessionPlan(playerId: string): Promise<SessionPlan | null> {
const res = await api(`curriculum/${playerId}/sessions/plans`)
if (!res.ok) throw new Error('Failed to fetch active session plan')
const data = await res.json()
return data.plan ?? null
}
/**
@@ -34,8 +27,8 @@ async function fetchActiveSessionPlan(
*/
export class ActiveSessionExistsClientError extends Error {
constructor(public readonly existingPlan: SessionPlan) {
super("Active session already exists");
this.name = "ActiveSessionExistsClientError";
super('Active session already exists')
this.name = 'ActiveSessionExistsClientError'
}
}
@@ -44,8 +37,8 @@ export class ActiveSessionExistsClientError extends Error {
*/
export class NoSkillsEnabledClientError extends Error {
constructor(message: string) {
super(message);
this.name = "NoSkillsEnabledClientError";
super(message)
this.name = 'NoSkillsEnabledClientError'
}
}
@@ -53,9 +46,9 @@ export class NoSkillsEnabledClientError extends Error {
* Which session parts to include
*/
interface EnabledParts {
abacus: boolean;
visualization: boolean;
linear: boolean;
abacus: boolean
visualization: boolean
linear: boolean
}
async function generateSessionPlan({
@@ -66,23 +59,20 @@ async function generateSessionPlan({
problemGenerationMode,
confidenceThreshold,
sessionMode,
gameBreakSettings,
}: {
playerId: string;
durationMinutes: number;
/** Max terms per problem for abacus part (visualization auto-calculates as 75%) */
abacusTermCount?: { min: number; max: number };
/** Which parts to include (default: all enabled) */
enabledParts?: EnabledParts;
/** Problem generation algorithm: 'adaptive-bkt' (full BKT), 'adaptive', or 'classic' */
problemGenerationMode?: ProblemGenerationMode;
/** BKT confidence threshold for identifying struggling skills */
confidenceThreshold?: number;
/** Pre-computed session mode for targeting consistency */
sessionMode?: SessionMode;
playerId: string
durationMinutes: number
abacusTermCount?: { min: number; max: number }
enabledParts?: EnabledParts
problemGenerationMode?: ProblemGenerationMode
confidenceThreshold?: number
sessionMode?: SessionMode
gameBreakSettings?: GameBreakSettings
}): Promise<SessionPlan> {
const res = await api(`curriculum/${playerId}/sessions/plans`, {
method: "POST",
headers: { "Content-Type": "application/json" },
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
durationMinutes,
abacusTermCount,
@@ -90,29 +80,30 @@ async function generateSessionPlan({
problemGenerationMode,
confidenceThreshold,
sessionMode,
gameBreakSettings,
}),
});
})
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
const errorData = await res.json().catch(() => ({}))
// Handle 409 conflict - active session exists
if (
res.status === 409 &&
errorData.code === "ACTIVE_SESSION_EXISTS" &&
errorData.code === 'ACTIVE_SESSION_EXISTS' &&
errorData.existingPlan
) {
throw new ActiveSessionExistsClientError(errorData.existingPlan);
throw new ActiveSessionExistsClientError(errorData.existingPlan)
}
// Handle 400 - no skills enabled
if (res.status === 400 && errorData.code === "NO_SKILLS_ENABLED") {
throw new NoSkillsEnabledClientError(errorData.error);
if (res.status === 400 && errorData.code === 'NO_SKILLS_ENABLED') {
throw new NoSkillsEnabledClientError(errorData.error)
}
throw new Error(errorData.error || "Failed to generate session plan");
throw new Error(errorData.error || 'Failed to generate session plan')
}
const data = await res.json();
return data.plan;
const data = await res.json()
return data.plan
}
async function updateSessionPlan({
@@ -122,23 +113,23 @@ async function updateSessionPlan({
result,
reason,
}: {
playerId: string;
planId: string;
action: "approve" | "start" | "record" | "end_early" | "abandon";
result?: Omit<SlotResult, "timestamp" | "partNumber">;
reason?: string;
playerId: string
planId: string
action: 'approve' | 'start' | 'record' | 'end_early' | 'abandon'
result?: Omit<SlotResult, 'timestamp' | 'partNumber'>
reason?: string
}): Promise<SessionPlan> {
const res = await api(`curriculum/${playerId}/sessions/plans/${planId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, result, reason }),
});
})
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(error.error || `Failed to ${action} session plan`);
const error = await res.json().catch(() => ({}))
throw new Error(error.error || `Failed to ${action} session plan`)
}
const data = await res.json();
return data.plan;
const data = await res.json()
return data.plan
}
// ============================================================================
@@ -151,12 +142,9 @@ async function updateSessionPlan({
* @param playerId - The player ID to fetch the session for
* @param initialData - Optional initial data from server-side props (avoids loading state on direct page load)
*/
export function useActiveSessionPlan(
playerId: string | null,
initialData?: SessionPlan | null,
) {
export function useActiveSessionPlan(playerId: string | null, initialData?: SessionPlan | null) {
return useQuery({
queryKey: sessionPlanKeys.active(playerId ?? ""),
queryKey: sessionPlanKeys.active(playerId ?? ''),
queryFn: () => fetchActiveSessionPlan(playerId!),
enabled: !!playerId,
// Use server-provided data as initial cache value
@@ -165,7 +153,7 @@ export function useActiveSessionPlan(
// Don't refetch on mount if we have initial data - trust the server
// The query will still refetch on window focus or after stale time
staleTime: initialData ? 30000 : 0, // 30s stale time if we have initial data
});
})
}
/**
@@ -175,72 +163,72 @@ export function useActiveSessionPlanSuspense(playerId: string) {
return useSuspenseQuery({
queryKey: sessionPlanKeys.active(playerId),
queryFn: () => fetchActiveSessionPlan(playerId),
});
})
}
/**
* Hook: Generate a new session plan
*/
export function useGenerateSessionPlan() {
const queryClient = useQueryClient();
const queryClient = useQueryClient()
return useMutation({
mutationFn: generateSessionPlan,
onSuccess: (plan, { playerId }) => {
// Update the active plan cache
queryClient.setQueryData(sessionPlanKeys.active(playerId), plan);
queryClient.setQueryData(sessionPlanKeys.active(playerId), plan)
// Also cache by plan ID
queryClient.setQueryData(sessionPlanKeys.detail(plan.id), plan);
queryClient.setQueryData(sessionPlanKeys.detail(plan.id), plan)
},
onError: (err) => {
console.error("Failed to generate session plan:", err.message);
console.error('Failed to generate session plan:', err.message)
},
});
})
}
/**
* Hook: Approve a session plan (teacher clicks "Let's Go!")
*/
export function useApproveSessionPlan() {
const queryClient = useQueryClient();
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ playerId, planId }: { playerId: string; planId: string }) =>
updateSessionPlan({ playerId, planId, action: "approve" }),
updateSessionPlan({ playerId, planId, action: 'approve' }),
onSuccess: (plan, { playerId }) => {
queryClient.setQueryData(sessionPlanKeys.active(playerId), plan);
queryClient.setQueryData(sessionPlanKeys.detail(plan.id), plan);
queryClient.setQueryData(sessionPlanKeys.active(playerId), plan)
queryClient.setQueryData(sessionPlanKeys.detail(plan.id), plan)
},
onError: (err) => {
console.error("Failed to approve session plan:", err.message);
console.error('Failed to approve session plan:', err.message)
},
});
})
}
/**
* Hook: Start a session plan (begin practice)
*/
export function useStartSessionPlan() {
const queryClient = useQueryClient();
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ playerId, planId }: { playerId: string; planId: string }) =>
updateSessionPlan({ playerId, planId, action: "start" }),
updateSessionPlan({ playerId, planId, action: 'start' }),
onSuccess: (plan, { playerId }) => {
queryClient.setQueryData(sessionPlanKeys.active(playerId), plan);
queryClient.setQueryData(sessionPlanKeys.detail(plan.id), plan);
queryClient.setQueryData(sessionPlanKeys.active(playerId), plan)
queryClient.setQueryData(sessionPlanKeys.detail(plan.id), plan)
},
onError: (err) => {
console.error("Failed to start session plan:", err.message);
console.error('Failed to start session plan:', err.message)
},
});
})
}
/**
* Hook: Record a slot result (answer submitted)
*/
export function useRecordSlotResult() {
const queryClient = useQueryClient();
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
@@ -248,25 +236,25 @@ export function useRecordSlotResult() {
planId,
result,
}: {
playerId: string;
planId: string;
result: Omit<SlotResult, "timestamp" | "partNumber">;
}) => updateSessionPlan({ playerId, planId, action: "record", result }),
playerId: string
planId: string
result: Omit<SlotResult, 'timestamp' | 'partNumber'>
}) => updateSessionPlan({ playerId, planId, action: 'record', result }),
onSuccess: (plan, { playerId }) => {
queryClient.setQueryData(sessionPlanKeys.active(playerId), plan);
queryClient.setQueryData(sessionPlanKeys.detail(plan.id), plan);
queryClient.setQueryData(sessionPlanKeys.active(playerId), plan)
queryClient.setQueryData(sessionPlanKeys.detail(plan.id), plan)
},
onError: (err) => {
console.error("Failed to record slot result:", err.message);
console.error('Failed to record slot result:', err.message)
},
});
})
}
/**
* Hook: End session early
*/
export function useEndSessionEarly() {
const queryClient = useQueryClient();
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
@@ -274,38 +262,38 @@ export function useEndSessionEarly() {
planId,
reason,
}: {
playerId: string;
planId: string;
reason?: string;
}) => updateSessionPlan({ playerId, planId, action: "end_early", reason }),
playerId: string
planId: string
reason?: string
}) => updateSessionPlan({ playerId, planId, action: 'end_early', reason }),
onSuccess: (plan, { playerId }) => {
queryClient.setQueryData(sessionPlanKeys.active(playerId), plan);
queryClient.setQueryData(sessionPlanKeys.detail(plan.id), plan);
queryClient.setQueryData(sessionPlanKeys.active(playerId), plan)
queryClient.setQueryData(sessionPlanKeys.detail(plan.id), plan)
// Invalidate the list to show in history
queryClient.invalidateQueries({ queryKey: sessionPlanKeys.lists() });
queryClient.invalidateQueries({ queryKey: sessionPlanKeys.lists() })
},
onError: (err) => {
console.error("Failed to end session early:", err.message);
console.error('Failed to end session early:', err.message)
},
});
})
}
/**
* Hook: Abandon session (user navigates away)
*/
export function useAbandonSession() {
const queryClient = useQueryClient();
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ playerId, planId }: { playerId: string; planId: string }) =>
updateSessionPlan({ playerId, planId, action: "abandon" }),
updateSessionPlan({ playerId, planId, action: 'abandon' }),
onSuccess: (plan, { playerId }) => {
queryClient.setQueryData(sessionPlanKeys.active(playerId), null);
queryClient.setQueryData(sessionPlanKeys.detail(plan.id), plan);
queryClient.invalidateQueries({ queryKey: sessionPlanKeys.lists() });
queryClient.setQueryData(sessionPlanKeys.active(playerId), null)
queryClient.setQueryData(sessionPlanKeys.detail(plan.id), plan)
queryClient.invalidateQueries({ queryKey: sessionPlanKeys.lists() })
},
onError: (err) => {
console.error("Failed to abandon session:", err.message);
console.error('Failed to abandon session:', err.message)
},
});
})
}

File diff suppressed because it is too large Load Diff