diff --git a/apps/web/src/components/classroom/SessionObserverModal.tsx b/apps/web/src/components/classroom/SessionObserverModal.tsx index 65c648e4..b664f9da 100644 --- a/apps/web/src/components/classroom/SessionObserverModal.tsx +++ b/apps/web/src/components/classroom/SessionObserverModal.tsx @@ -214,7 +214,7 @@ export function SessionObserverModal({ {state && ( <> {/* Purpose badge with tooltip - matches student's view */} - + {/* Problem */} { if (!onBroadcastStateChange) return + // Helper to extract complexity data from a slot + const extractComplexity = (slot: ProblemSlot | undefined): BroadcastComplexity | undefined => { + if (!slot) return undefined + + const bounds = slot.complexityBounds + const trace = slot.problem?.generationTrace + const hasBounds = bounds && (bounds.min !== undefined || bounds.max !== undefined) + const hasCost = trace?.totalComplexityCost !== undefined + + if (!hasBounds && !hasCost) return undefined + + return { + bounds: hasBounds ? { min: bounds?.min, max: bounds?.max } : undefined, + totalCost: trace?.totalComplexityCost, + stepCount: trace?.steps?.length, + targetSkillName: extractTargetSkillName(slot) ?? undefined, + } + } + // Get current slot's purpose from plan const currentPart = plan.parts[plan.currentPartIndex] const slot = currentPart?.slots[plan.currentSlotIndex] @@ -617,7 +652,7 @@ export function ActiveSession({ // During transitioning, we show the outgoing (completed) problem, not the incoming one // But we use the PREVIOUS slot's purpose since we're still showing feedback for it if (phase.phase === 'transitioning' && outgoingAttempt) { - // During transition, use the previous slot's purpose + // During transition, use the previous slot's purpose and complexity const prevSlotIndex = plan.currentSlotIndex > 0 ? plan.currentSlotIndex - 1 : 0 const prevSlot = currentPart?.slots[prevSlotIndex] const prevPurpose = prevSlot?.purpose ?? purpose @@ -632,6 +667,7 @@ export function ActiveSession({ isCorrect: outgoingAttempt.result === 'correct', startedAt: attempt?.startTime ?? Date.now(), purpose: prevPurpose, + complexity: extractComplexity(prevSlot), }) return } @@ -668,6 +704,7 @@ export function ActiveSession({ isCorrect, startedAt: attempt.startTime, purpose, + complexity: extractComplexity(slot), }) }, [ onBroadcastStateChange, diff --git a/apps/web/src/components/practice/PurposeBadge.tsx b/apps/web/src/components/practice/PurposeBadge.tsx index 31c23cfa..5172cc0d 100644 --- a/apps/web/src/components/practice/PurposeBadge.tsx +++ b/apps/web/src/components/practice/PurposeBadge.tsx @@ -7,11 +7,28 @@ import { css } from '../../../styled-system/css' type Purpose = 'focus' | 'reinforce' | 'review' | 'challenge' +/** + * Minimal complexity data for tooltip display + * Used when full slot isn't available (e.g., in session observer) + */ +export interface ComplexityData { + /** Complexity bounds from slot constraints */ + bounds?: { min?: number; max?: number } + /** Total complexity cost from generation trace */ + totalCost?: number + /** Number of steps (for per-term average) */ + stepCount?: number + /** Pre-formatted target skill name */ + targetSkillName?: string +} + interface PurposeBadgeProps { /** The purpose type */ purpose: Purpose /** Optional slot for detailed tooltip (when available) */ slot?: ProblemSlot + /** Optional simplified complexity data (alternative to slot, for observers) */ + complexity?: ComplexityData } /** @@ -73,18 +90,24 @@ function formatSkillName(category: string, skillKey: string): string { /** * Complexity section for purpose tooltip - shows complexity bounds and actual costs + * Accepts either a full slot or simplified complexity data */ function ComplexitySection({ slot, + complexity, showBounds = true, }: { - slot: ProblemSlot + slot?: ProblemSlot + complexity?: ComplexityData showBounds?: boolean }) { - const trace = slot.problem?.generationTrace - const bounds = slot.complexityBounds + // Extract data from slot or use simplified complexity data + const bounds = slot?.complexityBounds ?? complexity?.bounds + const totalCost = slot?.problem?.generationTrace?.totalComplexityCost ?? complexity?.totalCost + const stepCount = slot?.problem?.generationTrace?.steps?.length ?? complexity?.stepCount + const hasBounds = bounds && (bounds.min !== undefined || bounds.max !== undefined) - const hasCost = trace?.totalComplexityCost !== undefined + const hasCost = totalCost !== undefined // Don't render anything if no complexity data if (!hasBounds && !hasCost) { @@ -149,14 +172,14 @@ function ComplexitySection({ {hasCost && (
Total cost: - {trace.totalComplexityCost} + {totalCost}
)} - {trace?.steps && trace.steps.length > 0 && ( + {hasCost && stepCount && stepCount > 0 && (
Per term (avg): - {(trace.totalComplexityCost! / trace.steps.length).toFixed(1)} + {(totalCost! / stepCount).toFixed(1)}
)} @@ -236,9 +259,18 @@ const purposeInfo: Record< /** * Purpose tooltip content - rich explanatory content for each purpose */ -function PurposeTooltipContent({ purpose, slot }: { purpose: Purpose; slot?: ProblemSlot }) { +function PurposeTooltipContent({ + purpose, + slot, + complexity, +}: { + purpose: Purpose + slot?: ProblemSlot + complexity?: ComplexityData +}) { const info = purposeInfo[purpose] - const skillName = slot ? extractTargetSkillName(slot) : null + // Get skill name from slot or from simplified complexity data + const skillName = slot ? extractTargetSkillName(slot) : complexity?.targetSkillName ?? null return (
@@ -287,8 +319,8 @@ function PurposeTooltipContent({ purpose, slot }: { purpose: Purpose; slot?: Pro
)} - {/* Complexity section only when slot is available */} - {slot && } + {/* Complexity section when slot or complexity data is available */} + {(slot || complexity) && } ) } @@ -301,7 +333,7 @@ function PurposeTooltipContent({ purpose, slot }: { purpose: Purpose; slot?: Pro * * Used by both the student's ActiveSession and the teacher's SessionObserverModal. */ -export function PurposeBadge({ purpose, slot }: PurposeBadgeProps) { +export function PurposeBadge({ purpose, slot, complexity }: PurposeBadgeProps) { const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' @@ -355,7 +387,7 @@ export function PurposeBadge({ purpose, slot }: PurposeBadgeProps) { return ( } + content={} side="bottom" delayDuration={300} > diff --git a/apps/web/src/hooks/useSessionBroadcast.ts b/apps/web/src/hooks/useSessionBroadcast.ts index de0085a1..c3cc68c7 100644 --- a/apps/web/src/hooks/useSessionBroadcast.ts +++ b/apps/web/src/hooks/useSessionBroadcast.ts @@ -49,6 +49,7 @@ export function useSessionBroadcast( elapsed: Date.now() - currentState.startedAt, }, purpose: currentState.purpose, + complexity: currentState.complexity, } socketRef.current.emit('practice-state', event) diff --git a/apps/web/src/hooks/useSessionObserver.ts b/apps/web/src/hooks/useSessionObserver.ts index d6281fa3..8b8ba242 100644 --- a/apps/web/src/hooks/useSessionObserver.ts +++ b/apps/web/src/hooks/useSessionObserver.ts @@ -4,6 +4,20 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { io, type Socket } from 'socket.io-client' import type { PracticeStateEvent } from '@/lib/classroom/socket-events' +/** + * Complexity data from broadcast + */ +export interface ObservedComplexity { + /** Complexity bounds from slot constraints */ + bounds?: { min?: number; max?: number } + /** Total complexity cost from generation trace */ + totalCost?: number + /** Number of steps (for per-term average) */ + stepCount?: number + /** Pre-formatted target skill name */ + targetSkillName?: string +} + /** * State of an observed practice session */ @@ -26,6 +40,8 @@ export interface ObservedSessionState { } /** Purpose of this problem slot (why it was selected) */ purpose: 'focus' | 'reinforce' | 'review' | 'challenge' + /** Complexity data for tooltip display */ + complexity?: ObservedComplexity /** When this state was received */ receivedAt: number } @@ -130,6 +146,7 @@ export function useSessionObserver( isCorrect: data.isCorrect, timing: data.timing, purpose: data.purpose, + complexity: data.complexity, receivedAt: Date.now(), }) }) diff --git a/apps/web/src/lib/classroom/socket-events.ts b/apps/web/src/lib/classroom/socket-events.ts index a1b2673c..bb75871a 100644 --- a/apps/web/src/lib/classroom/socket-events.ts +++ b/apps/web/src/lib/classroom/socket-events.ts @@ -122,6 +122,20 @@ export interface PresenceRemovedEvent { // Session Observation Events (sent to session:${sessionId} channel) // ============================================================================ +/** + * Complexity data for broadcast (simplified for transmission) + */ +export interface BroadcastComplexity { + /** Complexity bounds from slot constraints */ + bounds?: { min?: number; max?: number } + /** Total complexity cost from generation trace */ + totalCost?: number + /** Number of steps (for per-term average) */ + stepCount?: number + /** Pre-formatted target skill name */ + targetSkillName?: string +} + export interface PracticeStateEvent { sessionId: string currentProblem: unknown // GeneratedProblem type from curriculum @@ -135,6 +149,8 @@ export interface PracticeStateEvent { } /** Purpose of this problem slot (why it was selected) */ purpose: 'focus' | 'reinforce' | 'review' | 'challenge' + /** Complexity data for tooltip display */ + complexity?: BroadcastComplexity } export interface TutorialStateEvent {