{
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 {