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:
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
181
apps/web/src/hooks/__tests__/useGameBreakTimer.test.ts
Normal file
181
apps/web/src/hooks/__tests__/useGameBreakTimer.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
107
apps/web/src/hooks/useGameBreakTimer.ts
Normal file
107
apps/web/src/hooks/useGameBreakTimer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user