diff --git a/apps/web/src/app/api/classrooms/[classroomId]/presence/[playerId]/route.ts b/apps/web/src/app/api/classrooms/[classroomId]/presence/[playerId]/route.ts
index 62eed5e4..da5468b0 100644
--- a/apps/web/src/app/api/classrooms/[classroomId]/presence/[playerId]/route.ts
+++ b/apps/web/src/app/api/classrooms/[classroomId]/presence/[playerId]/route.ts
@@ -48,7 +48,8 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) {
)
}
- await leaveSpecificClassroom(playerId, classroomId)
+ // Pass 'teacher' if removed by teacher, 'self' otherwise (parent removing their child)
+ await leaveSpecificClassroom(playerId, classroomId, isTeacher ? 'teacher' : 'self')
return NextResponse.json({ success: true })
} catch (error) {
diff --git a/apps/web/src/app/api/enrollment-requests/[requestId]/approve/route.ts b/apps/web/src/app/api/enrollment-requests/[requestId]/approve/route.ts
index cd66c0d7..1bb4d472 100644
--- a/apps/web/src/app/api/enrollment-requests/[requestId]/approve/route.ts
+++ b/apps/web/src/app/api/enrollment-requests/[requestId]/approve/route.ts
@@ -1,10 +1,26 @@
-import { type NextRequest, NextResponse } from 'next/server'
-import { approveEnrollmentRequest, isParent } from '@/lib/classroom'
-import { db } from '@/db'
-import { enrollmentRequests } from '@/db/schema'
import { eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { db, schema } from '@/db'
+import { enrollmentRequests } from '@/db/schema'
+import { approveEnrollmentRequest, isParent } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
+/**
+ * Get or create user record for a viewerId (guestId)
+ */
+async function getOrCreateUser(viewerId: string) {
+ let user = await db.query.users.findFirst({
+ where: eq(schema.users.guestId, viewerId),
+ })
+
+ if (!user) {
+ const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
+ user = newUser
+ }
+
+ return user
+}
+
interface RouteParams {
params: Promise<{ requestId: string }>
}
@@ -19,6 +35,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { requestId } = await params
const viewerId = await getViewerId()
+ const user = await getOrCreateUser(viewerId)
// Get the request to verify parent owns the child
const request = await db.query.enrollmentRequests.findFirst({
@@ -30,12 +47,12 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
}
// Verify user is a parent of the child in the request
- const parentCheck = await isParent(viewerId, request.playerId)
+ const parentCheck = await isParent(user.id, request.playerId)
if (!parentCheck) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
- const result = await approveEnrollmentRequest(requestId, viewerId, 'parent')
+ const result = await approveEnrollmentRequest(requestId, user.id, 'parent')
return NextResponse.json({
request: result.request,
diff --git a/apps/web/src/app/api/enrollment-requests/[requestId]/deny/route.ts b/apps/web/src/app/api/enrollment-requests/[requestId]/deny/route.ts
index b9a6578d..f0fa199c 100644
--- a/apps/web/src/app/api/enrollment-requests/[requestId]/deny/route.ts
+++ b/apps/web/src/app/api/enrollment-requests/[requestId]/deny/route.ts
@@ -1,10 +1,26 @@
-import { type NextRequest, NextResponse } from 'next/server'
-import { denyEnrollmentRequest, isParent } from '@/lib/classroom'
-import { db } from '@/db'
-import { enrollmentRequests } from '@/db/schema'
import { eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { db, schema } from '@/db'
+import { enrollmentRequests } from '@/db/schema'
+import { denyEnrollmentRequest, isParent } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
+/**
+ * Get or create user record for a viewerId (guestId)
+ */
+async function getOrCreateUser(viewerId: string) {
+ let user = await db.query.users.findFirst({
+ where: eq(schema.users.guestId, viewerId),
+ })
+
+ if (!user) {
+ const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
+ user = newUser
+ }
+
+ return user
+}
+
interface RouteParams {
params: Promise<{ requestId: string }>
}
@@ -19,6 +35,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { requestId } = await params
const viewerId = await getViewerId()
+ const user = await getOrCreateUser(viewerId)
// Get the request to verify parent owns the child
const request = await db.query.enrollmentRequests.findFirst({
@@ -30,12 +47,12 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
}
// Verify user is a parent of the child in the request
- const parentCheck = await isParent(viewerId, request.playerId)
+ const parentCheck = await isParent(user.id, request.playerId)
if (!parentCheck) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
- const updatedRequest = await denyEnrollmentRequest(requestId, viewerId, 'parent')
+ const updatedRequest = await denyEnrollmentRequest(requestId, user.id, 'parent')
return NextResponse.json({ request: updatedRequest })
} catch (error) {
diff --git a/apps/web/src/app/api/enrollment-requests/pending/route.ts b/apps/web/src/app/api/enrollment-requests/pending/route.ts
index 269b9e79..60d102a0 100644
--- a/apps/web/src/app/api/enrollment-requests/pending/route.ts
+++ b/apps/web/src/app/api/enrollment-requests/pending/route.ts
@@ -1,18 +1,40 @@
+import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
+import { db, schema } from '@/db'
import { getPendingRequestsForParent } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
+/**
+ * Get or create user record for a viewerId (guestId)
+ */
+async function getOrCreateUser(viewerId: string) {
+ let user = await db.query.users.findFirst({
+ where: eq(schema.users.guestId, viewerId),
+ })
+
+ if (!user) {
+ const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
+ user = newUser
+ }
+
+ return user
+}
+
/**
* GET /api/enrollment-requests/pending
* Get enrollment requests pending current user's approval as parent
*
- * Returns: { requests: EnrollmentRequest[] }
+ * These are requests initiated by teachers for the user's children,
+ * where parent approval hasn't been given yet.
+ *
+ * Returns: { requests: EnrollmentRequestWithRelations[] }
*/
export async function GET() {
try {
const viewerId = await getViewerId()
+ const user = await getOrCreateUser(viewerId)
- const requests = await getPendingRequestsForParent(viewerId)
+ const requests = await getPendingRequestsForParent(user.id)
return NextResponse.json({ requests })
} catch (error) {
diff --git a/apps/web/src/app/api/players/[id]/enrolled-classrooms/route.ts b/apps/web/src/app/api/players/[id]/enrolled-classrooms/route.ts
new file mode 100644
index 00000000..959547b8
--- /dev/null
+++ b/apps/web/src/app/api/players/[id]/enrolled-classrooms/route.ts
@@ -0,0 +1,33 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { getEnrolledClassrooms, canPerformAction } from '@/lib/classroom'
+import { getDbUserId } from '@/lib/viewer'
+
+interface RouteParams {
+ params: Promise<{ id: string }>
+}
+
+/**
+ * GET /api/players/[id]/enrolled-classrooms
+ * Get classrooms that this student is enrolled in
+ *
+ * Returns: { classrooms: Classroom[] }
+ */
+export async function GET(req: NextRequest, { params }: RouteParams) {
+ try {
+ const { id: playerId } = await params
+ const userId = await getDbUserId()
+
+ // Check authorization: must have at least view access
+ const canView = await canPerformAction(userId, playerId, 'view')
+ if (!canView) {
+ return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
+ }
+
+ const classrooms = await getEnrolledClassrooms(playerId)
+
+ return NextResponse.json({ classrooms })
+ } catch (error) {
+ console.error('Failed to fetch enrolled classrooms:', error)
+ return NextResponse.json({ error: 'Failed to fetch enrolled classrooms' }, { status: 500 })
+ }
+}
diff --git a/apps/web/src/app/api/players/[id]/presence/route.ts b/apps/web/src/app/api/players/[id]/presence/route.ts
index c2f4d3ca..c7e5db8f 100644
--- a/apps/web/src/app/api/players/[id]/presence/route.ts
+++ b/apps/web/src/app/api/players/[id]/presence/route.ts
@@ -1,6 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getStudentPresence, canPerformAction } from '@/lib/classroom'
-import { getViewerId } from '@/lib/viewer'
+import { getDbUserId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ id: string }>
@@ -15,10 +15,10 @@ interface RouteParams {
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { id: playerId } = await params
- const viewerId = await getViewerId()
+ const userId = await getDbUserId()
// Check authorization: must have at least view access
- const canView = await canPerformAction(viewerId, playerId, 'view')
+ const canView = await canPerformAction(userId, playerId, 'view')
if (!canView) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
diff --git a/apps/web/src/app/practice/PracticeClient.tsx b/apps/web/src/app/practice/PracticeClient.tsx
index 65151efb..3a695aff 100644
--- a/apps/web/src/app/practice/PracticeClient.tsx
+++ b/apps/web/src/app/practice/PracticeClient.tsx
@@ -3,7 +3,12 @@
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useState } from 'react'
import { Z_INDEX } from '@/constants/zIndex'
-import { ClassroomDashboard, CreateClassroomForm, EnrollChildFlow } from '@/components/classroom'
+import {
+ ClassroomDashboard,
+ CreateClassroomForm,
+ EnrollChildFlow,
+ PendingApprovalsSection,
+} from '@/components/classroom'
import { PageWithNav } from '@/components/PageWithNav'
import { StudentFilterBar } from '@/components/practice/StudentFilterBar'
import { StudentSelector, type StudentWithProgress } from '@/components/practice'
@@ -133,7 +138,7 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
return next
})
} else {
- router.push(`/practice/${student.id}/resume`, { scroll: false })
+ router.push(`/practice/${student.id}/dashboard`, { scroll: false })
}
},
[router, editMode]
@@ -394,6 +399,9 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
)}
+ {/* Pending Enrollment Approvals - for parents to approve teacher-initiated requests */}
+
+
{/* Needs Attention Section - uses same bucket styling as other sections */}
{studentsNeedingAttention.length > 0 && (
diff --git a/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx b/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx
deleted file mode 100644
index dbd8a422..00000000
--- a/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx
+++ /dev/null
@@ -1,1021 +0,0 @@
-'use client'
-
-import { useQueryClient } from '@tanstack/react-query'
-import { useRouter } from 'next/navigation'
-import { useCallback, useMemo, useState } from 'react'
-import { PageWithNav } from '@/components/PageWithNav'
-import { PracticeSubNav } from '@/components/practice'
-import { useTheme } from '@/contexts/ThemeContext'
-import type { Player } from '@/db/schema/players'
-import type { SessionPlan } from '@/db/schema/session-plans'
-import { DEFAULT_PLAN_CONFIG } from '@/db/schema/session-plans'
-import {
- ActiveSessionExistsClientError,
- sessionPlanKeys,
- useApproveSessionPlan,
- useGenerateSessionPlan,
- useStartSessionPlan,
-} from '@/hooks/useSessionPlan'
-import { css } from '../../../../../styled-system/css'
-
-// Plan configuration constants (from DEFAULT_PLAN_CONFIG)
-const PART_TIME_WEIGHTS = DEFAULT_PLAN_CONFIG.partTimeWeights
-const PURPOSE_WEIGHTS = {
- focus: DEFAULT_PLAN_CONFIG.focusWeight,
- reinforce: DEFAULT_PLAN_CONFIG.reinforceWeight,
- review: DEFAULT_PLAN_CONFIG.reviewWeight,
- // Note: Challenge slots are calculated as remainder after focus/reinforce/review
- // and use CHALLENGE_RATIO_BY_PART_TYPE in the actual session planner
-}
-
-interface ConfigureClientProps {
- studentId: string
- player: Player
- /** If there's an existing draft plan, pass it here */
- existingPlan?: SessionPlan | null
- /** Current phase focus description from curriculum */
- focusDescription: string
- /** Average seconds per problem based on student's history */
- avgSecondsPerProblem: number
- /** List of mastered skill IDs that will be used in problem generation */
- masteredSkillIds: string[]
-}
-
-/**
- * Session structure part types with their display info
- */
-const PART_INFO = [
- {
- type: 'abacus' as const,
- emoji: '🧮',
- label: 'Use Abacus',
- description: 'Practice with the physical abacus',
- },
- {
- type: 'visualization' as const,
- emoji: '🧠',
- label: 'Visualization',
- description: 'Mental math by picturing beads',
- },
- {
- type: 'linear' as const,
- emoji: '💭',
- label: 'Mental Math',
- description: 'Solve problems in your head',
- },
-]
-
-/**
- * Get part type colors (dark mode aware)
- */
-function getPartTypeColors(
- type: 'abacus' | 'visualization' | 'linear',
- isDark: boolean
-): { bg: string; border: string; text: string } {
- switch (type) {
- case 'abacus':
- return isDark
- ? { bg: 'blue.900', border: 'blue.700', text: 'blue.200' }
- : { bg: 'blue.50', border: 'blue.200', text: 'blue.700' }
- case 'visualization':
- return isDark
- ? { bg: 'purple.900', border: 'purple.700', text: 'purple.200' }
- : { bg: 'purple.50', border: 'purple.200', text: 'purple.700' }
- case 'linear':
- return isDark
- ? { bg: 'orange.900', border: 'orange.700', text: 'orange.200' }
- : { bg: 'orange.50', border: 'orange.200', text: 'orange.700' }
- }
-}
-
-/**
- * Skill category display names
- */
-const SKILL_CATEGORY_NAMES: Record
= {
- basic: 'Basic',
- fiveComplements: '5-Complements',
- fiveComplementsSub: '5-Complements (Sub)',
- tenComplements: '10-Complements',
- tenComplementsSub: '10-Complements (Sub)',
- advanced: 'Advanced',
-}
-
-/**
- * Group and format skill IDs for display
- */
-function groupSkillsByCategory(skillIds: string[]): Map {
- const grouped = new Map()
-
- for (const skillId of skillIds) {
- const [category, ...rest] = skillId.split('.')
- const skillName = rest.join('.')
-
- if (!grouped.has(category)) {
- grouped.set(category, [])
- }
- grouped.get(category)!.push(skillName)
- }
-
- return grouped
-}
-
-/**
- * Enabled parts configuration
- */
-type EnabledParts = {
- abacus: boolean
- visualization: boolean
- linear: boolean
-}
-
-/**
- * Calculate estimated session breakdown based on duration and enabled parts
- */
-function calculateEstimates(
- durationMinutes: number,
- avgSecondsPerProblem: number,
- enabledParts: EnabledParts
-) {
- // Filter to only enabled parts and recalculate weights
- const enabledPartTypes = (['abacus', 'visualization', 'linear'] as const).filter(
- (type) => enabledParts[type]
- )
-
- // If no parts enabled, return zeros
- if (enabledPartTypes.length === 0) {
- return {
- totalProblems: 0,
- parts: [
- {
- type: 'abacus' as const,
- weight: 0,
- minutes: 0,
- problems: 0,
- enabled: false,
- },
- {
- type: 'visualization' as const,
- weight: 0,
- minutes: 0,
- problems: 0,
- enabled: false,
- },
- {
- type: 'linear' as const,
- weight: 0,
- minutes: 0,
- problems: 0,
- enabled: false,
- },
- ],
- purposes: { focus: 0, reinforce: 0, review: 0, challenge: 0 },
- }
- }
-
- // Calculate total weight of enabled parts
- const totalEnabledWeight = enabledPartTypes.reduce(
- (sum, type) => sum + PART_TIME_WEIGHTS[type],
- 0
- )
-
- const totalProblems = Math.max(3, Math.floor((durationMinutes * 60) / avgSecondsPerProblem))
-
- // Calculate problems per part based on normalized weights
- const parts = (['abacus', 'visualization', 'linear'] as const).map((type) => {
- const enabled = enabledParts[type]
- if (!enabled) {
- return { type, weight: 0, minutes: 0, problems: 0, enabled: false }
- }
- const normalizedWeight = PART_TIME_WEIGHTS[type] / totalEnabledWeight
- return {
- type,
- weight: normalizedWeight,
- minutes: Math.round(durationMinutes * normalizedWeight),
- problems: Math.max(1, Math.round(totalProblems * normalizedWeight)),
- enabled: true,
- }
- })
-
- // Recalculate actual total problems from enabled parts
- const actualTotalProblems = parts.reduce((sum, p) => sum + p.problems, 0)
-
- // Calculate purpose breakdown
- const focusCount = Math.round(actualTotalProblems * PURPOSE_WEIGHTS.focus)
- const reinforceCount = Math.round(actualTotalProblems * PURPOSE_WEIGHTS.reinforce)
- const reviewCount = Math.round(actualTotalProblems * PURPOSE_WEIGHTS.review)
- const challengeCount = Math.max(
- 0,
- actualTotalProblems - focusCount - reinforceCount - reviewCount
- )
-
- return {
- totalProblems: actualTotalProblems,
- parts,
- purposes: {
- focus: focusCount,
- reinforce: reinforceCount,
- review: reviewCount,
- challenge: challengeCount,
- },
- }
-}
-
-/**
- * Unified session configuration and preview component
- *
- * Features:
- * - Duration selector that updates preview in real-time
- * - Live preview showing estimated problems, session structure, problem breakdown
- * - Single "Let's Go!" button that generates + approves + starts the session
- * - Handles existing draft plans gracefully
- */
-export function ConfigureClient({
- studentId,
- player,
- existingPlan,
- focusDescription,
- avgSecondsPerProblem,
- masteredSkillIds,
-}: ConfigureClientProps) {
- const router = useRouter()
- const queryClient = useQueryClient()
- const { resolvedTheme } = useTheme()
- const isDark = resolvedTheme === 'dark'
-
- // Duration state - use existing plan's duration if available
- const [durationMinutes, setDurationMinutes] = useState(existingPlan?.targetDurationMinutes ?? 10)
-
- // Term count state - max terms per problem for abacus part
- const [abacusMaxTerms, setAbacusMaxTerms] = useState(DEFAULT_PLAN_CONFIG.abacusTermCount.max)
-
- // Enabled parts state - which session parts to include
- const [enabledParts, setEnabledParts] = useState({
- abacus: true,
- visualization: true,
- linear: true,
- })
-
- // Toggle a part on/off
- const togglePart = useCallback((partType: keyof EnabledParts) => {
- setEnabledParts((prev) => {
- // Don't allow disabling the last enabled part
- const enabledCount = Object.values(prev).filter(Boolean).length
- if (enabledCount === 1 && prev[partType]) {
- return prev
- }
- return { ...prev, [partType]: !prev[partType] }
- })
- }, [])
-
- // Calculate visualization max terms (75% of abacus)
- const visualizationMaxTerms = Math.max(2, Math.round(abacusMaxTerms * 0.75))
-
- // Calculate live estimates based on current duration and enabled parts
- const estimates = useMemo(
- () => calculateEstimates(durationMinutes, avgSecondsPerProblem, enabledParts),
- [durationMinutes, avgSecondsPerProblem, enabledParts]
- )
-
- const generatePlan = useGenerateSessionPlan()
- const approvePlan = useApproveSessionPlan()
- const startPlan = useStartSessionPlan()
-
- // Combined loading state
- const isStarting = generatePlan.isPending || approvePlan.isPending || startPlan.isPending
-
- // Error state
- const error =
- (generatePlan.error && !(generatePlan.error instanceof ActiveSessionExistsClientError)) ||
- approvePlan.error ||
- startPlan.error
- ? {
- message: 'Unable to start session',
- suggestion: 'This may be a temporary issue. Try again or refresh the page.',
- }
- : null
-
- /**
- * Handle "Let's Go!" click - generates, approves, and starts the session in one flow
- */
- const handleStart = useCallback(async () => {
- generatePlan.reset()
- approvePlan.reset()
- startPlan.reset()
-
- try {
- let plan: SessionPlan
-
- // If we have an existing draft plan with the same duration, use it
- if (existingPlan && existingPlan.targetDurationMinutes === durationMinutes) {
- plan = existingPlan
- } else {
- // Generate a new plan
- try {
- plan = await generatePlan.mutateAsync({
- playerId: studentId,
- durationMinutes,
- abacusTermCount: { min: 3, max: abacusMaxTerms },
- enabledParts,
- })
- } catch (err) {
- if (err instanceof ActiveSessionExistsClientError) {
- // Use the existing plan
- plan = err.existingPlan
- queryClient.setQueryData(sessionPlanKeys.active(studentId), plan)
- } else {
- throw err
- }
- }
- }
-
- // Approve the plan
- await approvePlan.mutateAsync({
- playerId: studentId,
- planId: plan.id,
- })
-
- // Start the plan
- await startPlan.mutateAsync({
- playerId: studentId,
- planId: plan.id,
- })
-
- // Redirect to practice page (shows first problem)
- router.push(`/practice/${studentId}`, { scroll: false })
- } catch {
- // Errors will show in the error state
- }
- }, [
- studentId,
- durationMinutes,
- abacusMaxTerms,
- enabledParts,
- existingPlan,
- generatePlan,
- approvePlan,
- startPlan,
- queryClient,
- router,
- ])
-
- return (
-
- {/* Practice Sub-Navigation */}
-
-
-
-
- {/* Header */}
-
-
- {/* Main Card - Duration + Preview */}
-
- {/* Duration Selector */}
-
-
-
- {[5, 10, 15, 20, 30, 45].map((mins) => (
-
- ))}
-
-
-
- {/* Terms per Problem Selector */}
-
-
-
- {[3, 4, 5, 6, 7, 8].map((terms) => (
-
- ))}
-
-
- 🧮 Abacus: up to {abacusMaxTerms} numbers • 🧠 Visualize: up to{' '}
- {visualizationMaxTerms} numbers (75%)
-
-
-
- {/* Live Preview - Summary */}
-
- {/* Problem Count - centered prominently */}
-
-
- {estimates.totalProblems}
-
- problems
-
-
-
-
- {/* Three-Part Structure */}
-
-
- Session Structure{' '}
-
- (tap to toggle)
-
-
-
- {PART_INFO.map((partInfo, index) => {
- const partEstimate = estimates.parts[index]
- const isEnabled = enabledParts[partInfo.type]
- const colors = getPartTypeColors(partInfo.type, isDark)
- return (
-
- )
- })}
-
-
-
- {/* Problem Type Breakdown */}
-
-
- Problem Mix
-
-
- {/* Focus */}
-
-
- {estimates.purposes.focus}
-
-
- Focus
-
-
-
- {/* Reinforce */}
-
-
- {estimates.purposes.reinforce}
-
-
- Reinforce
-
-
-
- {/* Review */}
-
-
- {estimates.purposes.review}
-
-
- Review
-
-
-
- {/* Challenge */}
-
-
- {estimates.purposes.challenge}
-
-
- Challenge
-
-
-
-
-
- {/* Mastered Skills Summary (collapsible) */}
-
-
- Mastered Skills ({masteredSkillIds.length})
-
-
-
- {masteredSkillIds.length === 0 ? (
-
- No skills marked as mastered yet. Go to Dashboard to set skills.
-
- ) : (
-
- {Array.from(groupSkillsByCategory(masteredSkillIds)).map(
- ([category, skills]) => (
-
-
- {SKILL_CATEGORY_NAMES[category] || category}
-
-
- {skills.map((skill) => (
-
- {skill}
-
- ))}
-
-
- )
- )}
-
- )}
-
-
-
-
-
- {/* Error display */}
- {error && (
-
-
-
⚠️
-
-
- {error.message}
-
-
- {error.suggestion}
-
-
-
-
- )}
-
- {/* Action Buttons */}
-
-
-
-
-
-
- )
-}
diff --git a/apps/web/src/app/practice/[studentId]/configure/page.tsx b/apps/web/src/app/practice/[studentId]/configure/page.tsx
deleted file mode 100644
index 3165e346..00000000
--- a/apps/web/src/app/practice/[studentId]/configure/page.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { redirect } from 'next/navigation'
-
-// Disable caching - session data should be fresh
-export const dynamic = 'force-dynamic'
-
-interface ConfigurePageProps {
- params: Promise<{ studentId: string }>
-}
-
-/**
- * Configure Practice Session Page - DEPRECATED
- *
- * This page now redirects to the dashboard. The session configuration
- * modal is accessible from the dashboard via the "Start Practice" button.
- *
- * URL: /practice/[studentId]/configure → redirects to /practice/[studentId]/dashboard
- */
-export default async function ConfigurePage({ params }: ConfigurePageProps) {
- const { studentId } = await params
-
- // Redirect to dashboard - the StartPracticeModal is now accessible from there
- redirect(`/practice/${studentId}/dashboard`)
-}
diff --git a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx
index d12ddb23..af005709 100644
--- a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx
+++ b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx
@@ -3,6 +3,7 @@
import { useQuery } from '@tanstack/react-query'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState } from 'react'
+import { EnterClassroomButton } from '@/components/classroom'
import { PageWithNav } from '@/components/PageWithNav'
import {
type ActiveSessionState,
@@ -30,6 +31,7 @@ import type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
import type { Player } from '@/db/schema/players'
import type { PracticeSession } from '@/db/schema/practice-sessions'
import type { SessionPlan } from '@/db/schema/session-plans'
+import { usePlayerPresenceSocket } from '@/hooks/usePlayerPresenceSocket'
import { useSessionMode } from '@/hooks/useSessionMode'
import type { SessionMode } from '@/lib/curriculum/session-mode'
import { useRefreshSkillRecency, useSetMasteredSkills } from '@/hooks/usePlayerCurriculum'
@@ -2522,6 +2524,10 @@ export function DashboardClient({
// Session mode - single source of truth for session planning decisions
const { data: sessionMode, isLoading: isLoadingSessionMode } = useSessionMode(studentId)
+ // Subscribe to player presence updates via WebSocket
+ // This ensures the UI updates when teacher removes student from classroom
+ usePlayerPresenceSocket(studentId)
+
// Tab state - sync with URL
const [activeTab, setActiveTab] = useState(initialTab)
@@ -2687,6 +2693,17 @@ export function DashboardClient({
})}
>
+ {/* Classroom presence - allows entering enrolled classrooms for live practice */}
+
+
+
+
{/* Session mode banner - renders in-flow, projects to nav on scroll */}
{
- router.push(`/practice/${studentId}`, { scroll: false })
- }, [studentId, router])
-
- // Handle starting fresh - abandon current session and go to configure
- const handleStartFresh = useCallback(() => {
- abandonSession.mutate(
- { playerId: studentId, planId: session.id },
- {
- onSuccess: () => {
- router.push(`/practice/${studentId}/configure`, { scroll: false })
- },
- }
- )
- }, [studentId, session.id, abandonSession, router])
-
- return (
-
- {/* Practice Sub-Navigation */}
-
-
-
-
- {/* Header */}
-
-
- {/* Continue Session Card */}
-
-
-
-
- )
-}
diff --git a/apps/web/src/app/practice/[studentId]/resume/page.tsx b/apps/web/src/app/practice/[studentId]/resume/page.tsx
deleted file mode 100644
index 5e570a0a..00000000
--- a/apps/web/src/app/practice/[studentId]/resume/page.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { redirect } from 'next/navigation'
-
-// Disable caching for this page
-export const dynamic = 'force-dynamic'
-
-interface ResumePageProps {
- params: Promise<{ studentId: string }>
-}
-
-/**
- * Resume Session Page - DEPRECATED
- *
- * This page now redirects to the main practice page. The "welcome back"
- * experience is now handled by the SessionPausedModal which shows automatically
- * when returning to an in-progress session.
- *
- * URL: /practice/[studentId]/resume → redirects to /practice/[studentId]
- */
-export default async function ResumePage({ params }: ResumePageProps) {
- const { studentId } = await params
-
- // The main practice page now handles the "welcome back" modal
- redirect(`/practice/${studentId}`)
-}
diff --git a/apps/web/src/components/classroom/ClassroomDashboard.tsx b/apps/web/src/components/classroom/ClassroomDashboard.tsx
index 6238c565..90a8befb 100644
--- a/apps/web/src/components/classroom/ClassroomDashboard.tsx
+++ b/apps/web/src/components/classroom/ClassroomDashboard.tsx
@@ -3,6 +3,7 @@
import { useState } from 'react'
import type { Classroom, Player } from '@/db/schema'
import { useTheme } from '@/contexts/ThemeContext'
+import { useClassroomSocket } from '@/hooks/useClassroomSocket'
import { css } from '../../../styled-system/css'
import { ClassroomCodeShare } from './ClassroomCodeShare'
import { ClassroomTab } from './ClassroomTab'
@@ -32,6 +33,10 @@ export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDas
const [activeTab, setActiveTab] = useState('classroom')
const [showEnrollChild, setShowEnrollChild] = useState(false)
+ // Subscribe to real-time classroom presence updates via WebSocket
+ // This is at the dashboard level so events are received even when on other tabs
+ useClassroomSocket(classroom.id)
+
const tabs: { id: TabId; label: string; icon: string }[] = [
{ id: 'classroom', label: 'Classroom', icon: '🏫' },
{ id: 'students', label: 'Student Manager', icon: '👥' },
diff --git a/apps/web/src/components/classroom/ClassroomTab.tsx b/apps/web/src/components/classroom/ClassroomTab.tsx
index 07cbdbcf..b8a8f50f 100644
--- a/apps/web/src/components/classroom/ClassroomTab.tsx
+++ b/apps/web/src/components/classroom/ClassroomTab.tsx
@@ -1,7 +1,10 @@
'use client'
+import { useCallback } from 'react'
+import Link from 'next/link'
import type { Classroom } from '@/db/schema'
import { useTheme } from '@/contexts/ThemeContext'
+import { useClassroomPresence, useLeaveClassroom, type PresenceStudent } from '@/hooks/useClassroom'
import { css } from '../../../styled-system/css'
import { ClassroomCodeShare } from './ClassroomCodeShare'
@@ -13,13 +16,28 @@ interface ClassroomTabProps {
* ClassroomTab - Shows live classroom view
*
* Displays students currently "present" in the classroom.
- * For Phase 3, this is an empty state.
- * Phase 6 will add presence functionality.
+ * Teachers can see who's actively practicing and remove students when needed.
*/
export function ClassroomTab({ classroom }: ClassroomTabProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
+ // Fetch present students
+ // Note: WebSocket subscription is in ClassroomDashboard (parent) so it stays
+ // connected even when user switches tabs
+ const { data: presentStudents = [], isLoading } = useClassroomPresence(classroom.id)
+ const leaveClassroom = useLeaveClassroom()
+
+ const handleRemoveStudent = useCallback(
+ (playerId: string) => {
+ leaveClassroom.mutate({
+ classroomId: classroom.id,
+ playerId,
+ })
+ },
+ [classroom.id, leaveClassroom]
+ )
+
return (
- {/* Empty state */}
-
+ {/* Present students section */}
+ {isLoading ? (
- 🏫
+ Loading classroom...
-
- No Students Present
-
-
- When students join your classroom for practice, they'll appear here. Share your classroom
- code to get started.
-
+ ) : presentStudents.length > 0 ? (
+
+
+
+ Students Present
+
+ {presentStudents.length}
+
+
-
-
+
+ {presentStudents.map((student) => (
+
handleRemoveStudent(student.id)}
+ isRemoving={
+ leaveClassroom.isPending && leaveClassroom.variables?.playerId === student.id
+ }
+ isDark={isDark}
+ />
+ ))}
+
+
+ ) : (
+ /* Empty state */
+
+
+ 🏫
+
+
+ No Students Present
+
+
+ When students join your classroom for practice, they'll appear here. Share your
+ classroom code to get started.
+
+
+
+
+ )}
{/* Instructions */}
Share your classroom code with parents
Parents enroll their child using the code
- Students appear here when they start practicing
+ When practicing, students can "enter" the classroom
+ You'll see them appear here in real-time
)
}
+
+// ============================================================================
+// Sub-components
+// ============================================================================
+
+interface PresentStudentCardProps {
+ student: PresenceStudent
+ onRemove: () => void
+ isRemoving: boolean
+ isDark: boolean
+}
+
+function PresentStudentCard({ student, onRemove, isRemoving, isDark }: PresentStudentCardProps) {
+ const enteredAt = new Date(student.enteredAt)
+ const timeAgo = getTimeAgo(enteredAt)
+
+ return (
+
+
+
+
+ {student.emoji}
+
+ {/* Online indicator */}
+
+
+
+
+ {student.name}
+
+
+ Joined {timeAgo}
+
+
+
+
+
+
+ )
+}
+
+/**
+ * Format a date as a relative time string (e.g., "2 minutes ago")
+ */
+function getTimeAgo(date: Date): string {
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffMinutes = Math.floor(diffMs / (1000 * 60))
+
+ if (diffMinutes < 1) return 'just now'
+ if (diffMinutes === 1) return '1 minute ago'
+ if (diffMinutes < 60) return `${diffMinutes} minutes ago`
+
+ const diffHours = Math.floor(diffMinutes / 60)
+ if (diffHours === 1) return '1 hour ago'
+ if (diffHours < 24) return `${diffHours} hours ago`
+
+ return 'over a day ago'
+}
diff --git a/apps/web/src/components/classroom/EnterClassroomButton.tsx b/apps/web/src/components/classroom/EnterClassroomButton.tsx
new file mode 100644
index 00000000..c18de787
--- /dev/null
+++ b/apps/web/src/components/classroom/EnterClassroomButton.tsx
@@ -0,0 +1,382 @@
+'use client'
+
+import { useCallback, useState } from 'react'
+import type { Classroom } from '@/db/schema'
+import { useTheme } from '@/contexts/ThemeContext'
+import {
+ useEnrolledClassrooms,
+ useStudentPresence,
+ useEnterClassroom,
+ useLeaveClassroom,
+} from '@/hooks/useClassroom'
+import { css } from '../../../styled-system/css'
+
+interface EnterClassroomButtonProps {
+ playerId: string
+ playerName: string
+}
+
+/**
+ * EnterClassroomButton - Allows students to enter/leave enrolled classrooms
+ *
+ * Shows:
+ * - Current classroom presence (if in one)
+ * - List of enrolled classrooms to enter
+ * - Enter/leave actions
+ */
+export function EnterClassroomButton({ playerId, playerName }: EnterClassroomButtonProps) {
+ const { resolvedTheme } = useTheme()
+ const isDark = resolvedTheme === 'dark'
+
+ const [isOpen, setIsOpen] = useState(false)
+
+ // Fetch enrolled classrooms and current presence
+ const { data: enrolledClassrooms = [], isLoading: loadingClassrooms } =
+ useEnrolledClassrooms(playerId)
+ const { data: currentPresence, isLoading: loadingPresence } = useStudentPresence(playerId)
+
+ // Mutations
+ const enterClassroom = useEnterClassroom()
+ const leaveClassroom = useLeaveClassroom()
+
+ const handleEnter = useCallback(
+ (classroomId: string) => {
+ enterClassroom.mutate(
+ { classroomId, playerId },
+ {
+ onSuccess: () => {
+ setIsOpen(false)
+ },
+ }
+ )
+ },
+ [enterClassroom, playerId]
+ )
+
+ const handleLeave = useCallback(() => {
+ if (!currentPresence) return
+ leaveClassroom.mutate({
+ classroomId: currentPresence.classroomId,
+ playerId,
+ })
+ }, [currentPresence, leaveClassroom, playerId])
+
+ // Don't show while loading or if not enrolled in any classrooms
+ if (loadingClassrooms || enrolledClassrooms.length === 0) {
+ return null
+ }
+
+ const isInClassroom = !!currentPresence
+ const currentClassroom = currentPresence?.classroom
+
+ return (
+
+ {/* Main button */}
+
+
+ {/* Dropdown menu */}
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
setIsOpen(false)}
+ />
+
+ {/* Menu */}
+
+
+
+ {playerName}'s Classrooms
+
+
+
+ {loadingClassrooms || loadingPresence ? (
+
+ Loading...
+
+ ) : (
+
+ {/* Current presence */}
+ {currentPresence && currentClassroom && (
+
+
+
+
+
+ Currently in
+
+
+
+
+
+ {currentClassroom.name}
+
+
+ )}
+
+ {/* Enrolled classrooms list */}
+ {enrolledClassrooms
+ .filter((c) => c.id !== currentPresence?.classroomId)
+ .map((classroom) => (
+
handleEnter(classroom.id)}
+ isEntering={
+ enterClassroom.isPending &&
+ enterClassroom.variables?.classroomId === classroom.id
+ }
+ isDisabled={!!currentPresence}
+ isDark={isDark}
+ />
+ ))}
+
+ {enrolledClassrooms.length === 0 && (
+
+ Not enrolled in any classrooms
+
+ )}
+
+ )}
+
+ >
+ )}
+
+ )
+}
+
+interface ClassroomMenuItemProps {
+ classroom: Classroom
+ onEnter: () => void
+ isEntering: boolean
+ isDisabled: boolean
+ isDark: boolean
+}
+
+function ClassroomMenuItem({
+ classroom,
+ onEnter,
+ isEntering,
+ isDisabled,
+ isDark,
+}: ClassroomMenuItemProps) {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/classroom/PendingApprovalsSection.tsx b/apps/web/src/components/classroom/PendingApprovalsSection.tsx
new file mode 100644
index 00000000..2ea4fa7a
--- /dev/null
+++ b/apps/web/src/components/classroom/PendingApprovalsSection.tsx
@@ -0,0 +1,255 @@
+'use client'
+
+import { useCallback } from 'react'
+import { useTheme } from '@/contexts/ThemeContext'
+import {
+ usePendingApprovalsForParent,
+ useApproveEnrollmentRequestAsParent,
+ useDenyEnrollmentRequestAsParent,
+ type EnrollmentRequestWithRelations,
+} from '@/hooks/useClassroom'
+import { css } from '../../../styled-system/css'
+
+/**
+ * PendingApprovalsSection - Shows pending enrollment requests for parents
+ *
+ * When a teacher initiates enrollment for a parent's child, the parent
+ * sees it here and can approve or deny.
+ */
+export function PendingApprovalsSection() {
+ const { resolvedTheme } = useTheme()
+ const isDark = resolvedTheme === 'dark'
+
+ const { data: requests = [], isLoading } = usePendingApprovalsForParent()
+ const approveRequest = useApproveEnrollmentRequestAsParent()
+ const denyRequest = useDenyEnrollmentRequestAsParent()
+
+ const handleApprove = useCallback(
+ (requestId: string) => {
+ approveRequest.mutate(requestId)
+ },
+ [approveRequest]
+ )
+
+ const handleDeny = useCallback(
+ (requestId: string) => {
+ denyRequest.mutate(requestId)
+ },
+ [denyRequest]
+ )
+
+ // Don't render if no pending requests
+ if (isLoading || requests.length === 0) {
+ return null
+ }
+
+ return (
+
+
+ 📬
+ Enrollment Requests
+
+ {requests.length}
+
+
+
+
+ A teacher has requested to enroll your child in their classroom.
+
+
+
+ {requests.map((request) => (
+
handleApprove(request.id)}
+ onDeny={() => handleDeny(request.id)}
+ isApproving={approveRequest.isPending && approveRequest.variables === request.id}
+ isDenying={denyRequest.isPending && denyRequest.variables === request.id}
+ isDark={isDark}
+ />
+ ))}
+
+
+ )
+}
+
+interface PendingApprovalCardProps {
+ request: EnrollmentRequestWithRelations
+ onApprove: () => void
+ onDeny: () => void
+ isApproving: boolean
+ isDenying: boolean
+ isDark: boolean
+}
+
+function PendingApprovalCard({
+ request,
+ onApprove,
+ onDeny,
+ isApproving,
+ isDenying,
+ isDark,
+}: PendingApprovalCardProps) {
+ const player = request.player
+ const classroom = request.classroom
+
+ return (
+
+
+
+ {player?.emoji ?? '?'}
+
+
+
+ {player?.name ?? 'Unknown Student'}
+
+
+ Classroom: {classroom?.name ?? 'Unknown'}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/classroom/StudentManagerTab.tsx b/apps/web/src/components/classroom/StudentManagerTab.tsx
index 8a02f04a..efde9522 100644
--- a/apps/web/src/components/classroom/StudentManagerTab.tsx
+++ b/apps/web/src/components/classroom/StudentManagerTab.tsx
@@ -402,7 +402,7 @@ function EnrolledStudentCard({
})}
>
{
+ const res = await api('enrollment-requests/pending')
+ if (!res.ok) throw new Error('Failed to fetch pending approvals')
+ const data = await res.json()
+ return data.requests
+}
+
+/**
+ * Approve enrollment request as parent
+ */
+async function approveRequestAsParent(
+ requestId: string
+): Promise<{ request: EnrollmentRequest; enrolled: boolean }> {
+ const res = await api(`enrollment-requests/${requestId}/approve`, { method: 'POST' })
+ if (!res.ok) {
+ const data = await res.json()
+ throw new Error(data.error || 'Failed to approve request')
+ }
+ return res.json()
+}
+
+/**
+ * Deny enrollment request as parent
+ */
+async function denyRequestAsParent(requestId: string): Promise
{
+ const res = await api(`enrollment-requests/${requestId}/deny`, { method: 'POST' })
+ if (!res.ok) {
+ const data = await res.json()
+ throw new Error(data.error || 'Failed to deny request')
+ }
+ const data = await res.json()
+ return data.request
+}
+
+// ============================================================================
+// Parent Enrollment Approval Hooks
+// ============================================================================
+
+/**
+ * Get pending enrollment requests for current user as parent
+ *
+ * These are requests initiated by teachers for the user's children,
+ * where parent approval hasn't been given yet.
+ */
+export function usePendingApprovalsForParent() {
+ return useQuery({
+ queryKey: classroomKeys.pendingParentApprovals(),
+ queryFn: fetchPendingApprovalsForParent,
+ staleTime: 30 * 1000, // 30 seconds
+ })
+}
+
+/**
+ * Approve enrollment request as parent
+ */
+export function useApproveEnrollmentRequestAsParent() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: approveRequestAsParent,
+ onSuccess: (result) => {
+ // Invalidate pending parent approvals
+ queryClient.invalidateQueries({
+ queryKey: classroomKeys.pendingParentApprovals(),
+ })
+ // If fully approved, classroom enrollments will be updated too
+ // (but we don't know the classroomId from this response, so broader invalidation)
+ if (result.enrolled) {
+ queryClient.invalidateQueries({
+ queryKey: ['classrooms'],
+ })
+ }
+ },
+ })
+}
+
+/**
+ * Deny enrollment request as parent
+ */
+export function useDenyEnrollmentRequestAsParent() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: denyRequestAsParent,
+ onSuccess: () => {
+ // Invalidate pending parent approvals
+ queryClient.invalidateQueries({
+ queryKey: classroomKeys.pendingParentApprovals(),
+ })
+ },
+ })
+}
+
+// ============================================================================
+// Classroom Presence API Functions
+// ============================================================================
+
+export interface PresenceStudent extends Player {
+ enteredAt: string
+ enteredBy: string
+}
+
+/**
+ * Fetch students currently present in a classroom
+ */
+async function fetchClassroomPresence(classroomId: string): Promise {
+ const res = await api(`classrooms/${classroomId}/presence`)
+ if (!res.ok) throw new Error('Failed to fetch classroom presence')
+ const data = await res.json()
+ return data.students
+}
+
+/**
+ * Enter a student into a classroom
+ */
+async function enterClassroom(params: {
+ classroomId: string
+ playerId: string
+}): Promise<{ success: boolean; error?: string }> {
+ const res = await api(`classrooms/${params.classroomId}/presence`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ playerId: params.playerId }),
+ })
+ const data = await res.json()
+ if (!res.ok) {
+ return { success: false, error: data.error || 'Failed to enter classroom' }
+ }
+ return { success: true }
+}
+
+/**
+ * Remove a student from a classroom
+ */
+async function leaveClassroom(params: { classroomId: string; playerId: string }): Promise {
+ const res = await api(`classrooms/${params.classroomId}/presence/${params.playerId}`, {
+ method: 'DELETE',
+ })
+ if (!res.ok) {
+ const data = await res.json()
+ throw new Error(data.error || 'Failed to leave classroom')
+ }
+}
+
+// ============================================================================
+// Classroom Presence Hooks
+// ============================================================================
+
+/**
+ * Get students currently present in a classroom
+ */
+export function useClassroomPresence(classroomId: string | undefined) {
+ return useQuery({
+ queryKey: classroomKeys.presence(classroomId ?? ''),
+ queryFn: () => fetchClassroomPresence(classroomId!),
+ enabled: !!classroomId,
+ staleTime: 15 * 1000, // 15 seconds - presence changes frequently
+ refetchInterval: 30 * 1000, // Poll every 30 seconds for real-time feel
+ })
+}
+
+/**
+ * Enter a student into a classroom
+ */
+export function useEnterClassroom() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: enterClassroom,
+ onSuccess: (result, { classroomId, playerId }) => {
+ if (result.success) {
+ // Invalidate teacher's view of classroom presence
+ queryClient.invalidateQueries({
+ queryKey: classroomKeys.presence(classroomId),
+ })
+ // Invalidate student's view of their own presence
+ queryClient.invalidateQueries({
+ queryKey: ['players', playerId, 'presence'],
+ })
+ }
+ },
+ })
+}
+
+/**
+ * Remove a student from a classroom
+ */
+export function useLeaveClassroom() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: leaveClassroom,
+ onSuccess: (_, { classroomId, playerId }) => {
+ // Invalidate teacher's view of classroom presence
+ queryClient.invalidateQueries({
+ queryKey: classroomKeys.presence(classroomId),
+ })
+ // Invalidate student's view of their own presence
+ queryClient.invalidateQueries({
+ queryKey: ['players', playerId, 'presence'],
+ })
+ },
+ })
+}
+
+// ============================================================================
+// Student Enrollment API Functions
+// ============================================================================
+
+export interface PresenceInfo {
+ playerId: string
+ classroomId: string
+ enteredAt: string
+ enteredBy: string
+ classroom?: Classroom
+}
+
+/**
+ * Fetch classrooms a student is enrolled in
+ */
+async function fetchEnrolledClassrooms(playerId: string): Promise {
+ const res = await api(`players/${playerId}/enrolled-classrooms`)
+ if (!res.ok) throw new Error('Failed to fetch enrolled classrooms')
+ const data = await res.json()
+ return data.classrooms
+}
+
+/**
+ * Fetch student's current classroom presence
+ */
+async function fetchStudentPresence(playerId: string): Promise {
+ const res = await api(`players/${playerId}/presence`)
+ if (!res.ok) throw new Error('Failed to fetch student presence')
+ const data = await res.json()
+ return data.presence
+}
+
+// ============================================================================
+// Student Enrollment Hooks
+// ============================================================================
+
+/**
+ * Get classrooms a student is enrolled in
+ */
+export function useEnrolledClassrooms(playerId: string | undefined) {
+ return useQuery({
+ queryKey: ['players', playerId, 'enrolled-classrooms'],
+ queryFn: () => fetchEnrolledClassrooms(playerId!),
+ enabled: !!playerId,
+ staleTime: 60 * 1000, // 1 minute
+ })
+}
+
+/**
+ * Get student's current classroom presence
+ */
+export function useStudentPresence(playerId: string | undefined) {
+ return useQuery({
+ queryKey: ['players', playerId, 'presence'],
+ queryFn: () => fetchStudentPresence(playerId!),
+ enabled: !!playerId,
+ staleTime: 30 * 1000, // 30 seconds
+ })
+}
diff --git a/apps/web/src/hooks/useClassroomSocket.ts b/apps/web/src/hooks/useClassroomSocket.ts
new file mode 100644
index 00000000..d0841f91
--- /dev/null
+++ b/apps/web/src/hooks/useClassroomSocket.ts
@@ -0,0 +1,74 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { io, type Socket } from 'socket.io-client'
+import { useQueryClient } from '@tanstack/react-query'
+import { classroomKeys } from '@/lib/queryKeys'
+import type { StudentEnteredEvent, StudentLeftEvent } from '@/lib/classroom/socket-events'
+
+/**
+ * Hook for real-time classroom presence updates via WebSocket
+ *
+ * When a student enters or leaves the classroom, this hook receives the event
+ * and automatically invalidates the React Query cache so the UI updates.
+ *
+ * @param classroomId - The classroom to subscribe to
+ * @returns Whether the socket is connected
+ */
+export function useClassroomSocket(classroomId: string | undefined): { connected: boolean } {
+ const [connected, setConnected] = useState(false)
+ const socketRef = useRef(null)
+ const queryClient = useQueryClient()
+
+ useEffect(() => {
+ if (!classroomId) return
+
+ // Create socket connection
+ const socket = io({
+ path: '/api/socket',
+ reconnection: true,
+ reconnectionDelay: 1000,
+ reconnectionAttempts: 5,
+ })
+ socketRef.current = socket
+
+ socket.on('connect', () => {
+ console.log('[ClassroomSocket] Connected')
+ setConnected(true)
+ // Join the classroom channel
+ socket.emit('join-classroom', { classroomId })
+ })
+
+ socket.on('disconnect', () => {
+ console.log('[ClassroomSocket] Disconnected')
+ setConnected(false)
+ })
+
+ // Listen for student entered event
+ socket.on('student-entered', (data: StudentEnteredEvent) => {
+ console.log('[ClassroomSocket] Student entered:', data.playerName)
+ // Invalidate the presence query to refetch
+ queryClient.invalidateQueries({
+ queryKey: classroomKeys.presence(classroomId),
+ })
+ })
+
+ // Listen for student left event
+ socket.on('student-left', (data: StudentLeftEvent) => {
+ console.log('[ClassroomSocket] Student left:', data.playerName)
+ // Invalidate the presence query to refetch
+ queryClient.invalidateQueries({
+ queryKey: classroomKeys.presence(classroomId),
+ })
+ })
+
+ // Cleanup on unmount
+ return () => {
+ socket.emit('leave-classroom', { classroomId })
+ socket.disconnect()
+ socketRef.current = null
+ }
+ }, [classroomId, queryClient])
+
+ return { connected }
+}
diff --git a/apps/web/src/hooks/usePlayerPresenceSocket.ts b/apps/web/src/hooks/usePlayerPresenceSocket.ts
new file mode 100644
index 00000000..d9128ec3
--- /dev/null
+++ b/apps/web/src/hooks/usePlayerPresenceSocket.ts
@@ -0,0 +1,65 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { io, type Socket } from 'socket.io-client'
+import { useQueryClient } from '@tanstack/react-query'
+import type { PresenceRemovedEvent } from '@/lib/classroom/socket-events'
+
+/**
+ * Hook for real-time player presence updates via WebSocket
+ *
+ * When a student is removed from a classroom (by teacher or parent),
+ * this hook receives the event and invalidates the React Query cache
+ * so the UI updates without requiring a page reload.
+ *
+ * @param playerId - The player to subscribe to
+ * @returns Whether the socket is connected
+ */
+export function usePlayerPresenceSocket(playerId: string | undefined): { connected: boolean } {
+ const [connected, setConnected] = useState(false)
+ const socketRef = useRef(null)
+ const queryClient = useQueryClient()
+
+ useEffect(() => {
+ if (!playerId) return
+
+ // Create socket connection
+ const socket = io({
+ path: '/api/socket',
+ reconnection: true,
+ reconnectionDelay: 1000,
+ reconnectionAttempts: 5,
+ })
+ socketRef.current = socket
+
+ socket.on('connect', () => {
+ console.log('[PlayerPresenceSocket] Connected')
+ setConnected(true)
+ // Join the player channel
+ socket.emit('join-player', { playerId })
+ })
+
+ socket.on('disconnect', () => {
+ console.log('[PlayerPresenceSocket] Disconnected')
+ setConnected(false)
+ })
+
+ // Listen for presence removed event (when teacher or parent removes student from classroom)
+ socket.on('presence-removed', (data: PresenceRemovedEvent) => {
+ console.log('[PlayerPresenceSocket] Presence removed:', data)
+ // Invalidate the student's presence query to refetch
+ queryClient.invalidateQueries({
+ queryKey: ['players', playerId, 'presence'],
+ })
+ })
+
+ // Cleanup on unmount
+ return () => {
+ socket.emit('leave-player', { playerId })
+ socket.disconnect()
+ socketRef.current = null
+ }
+ }, [playerId, queryClient])
+
+ return { connected }
+}
diff --git a/apps/web/src/lib/classroom/presence-manager.ts b/apps/web/src/lib/classroom/presence-manager.ts
index c141c00f..a08ed75d 100644
--- a/apps/web/src/lib/classroom/presence-manager.ts
+++ b/apps/web/src/lib/classroom/presence-manager.ts
@@ -19,10 +19,12 @@ import {
classroomEnrollments,
classroomPresence,
classrooms,
+ players,
type ClassroomPresence,
type Classroom,
type Player,
} from '@/db/schema'
+import { getSocketIO } from '@/lib/socket-io'
// ============================================================================
// Enter/Leave Classroom
@@ -97,6 +99,20 @@ export async function enterClassroom(params: EnterClassroomParams): Promise {
+ // Get current presence before deleting (need classroomId for socket event)
+ const presence = await db.query.classroomPresence.findFirst({
+ where: eq(classroomPresence.playerId, playerId),
+ })
+
+ if (!presence) return
+
await db.delete(classroomPresence).where(eq(classroomPresence.playerId, playerId))
+
+ // Emit socket event for real-time updates to teacher
+ const io = await getSocketIO()
+ if (io) {
+ const player = await db.query.players.findFirst({
+ where: eq(players.id, playerId),
+ })
+ io.to(`classroom:${presence.classroomId}`).emit('student-left', {
+ playerId,
+ playerName: player?.name ?? 'Unknown',
+ })
+ }
}
/**
* Remove a student from a specific classroom (if they're in it)
+ *
+ * @param removedBy - Who initiated the removal: 'teacher' or 'self'
*/
-export async function leaveSpecificClassroom(playerId: string, classroomId: string): Promise {
- await db
+export async function leaveSpecificClassroom(
+ playerId: string,
+ classroomId: string,
+ removedBy: 'teacher' | 'self' = 'self'
+): Promise {
+ const deleted = await db
.delete(classroomPresence)
.where(
and(eq(classroomPresence.playerId, playerId), eq(classroomPresence.classroomId, classroomId))
)
+ .returning()
+
+ // Only emit if something was actually deleted
+ if (deleted.length > 0) {
+ const io = await getSocketIO()
+ if (io) {
+ const player = await db.query.players.findFirst({
+ where: eq(players.id, playerId),
+ })
+ // Notify the classroom (for teacher's view)
+ io.to(`classroom:${classroomId}`).emit('student-left', {
+ playerId,
+ playerName: player?.name ?? 'Unknown',
+ })
+ // Notify the player (for student's view)
+ io.to(`player:${playerId}`).emit('presence-removed', {
+ classroomId,
+ removedBy,
+ })
+ }
+ }
}
/**
diff --git a/apps/web/src/lib/classroom/socket-events.ts b/apps/web/src/lib/classroom/socket-events.ts
index 188f5a21..dc1136e2 100644
--- a/apps/web/src/lib/classroom/socket-events.ts
+++ b/apps/web/src/lib/classroom/socket-events.ts
@@ -51,6 +51,15 @@ export interface StudentLeftEvent {
playerName: string
}
+// ============================================================================
+// Player Presence Events (sent to player:${playerId} channel)
+// ============================================================================
+
+export interface PresenceRemovedEvent {
+ classroomId: string
+ removedBy: 'teacher' | 'self'
+}
+
// ============================================================================
// Session Observation Events (sent to session:${sessionId} channel)
// ============================================================================
@@ -108,10 +117,13 @@ export interface ClassroomServerToClientEvents {
'enrollment-approved': (data: EnrollmentApprovedEvent) => void
'enrollment-denied': (data: EnrollmentDeniedEvent) => void
- // Presence events
+ // Presence events (classroom channel)
'student-entered': (data: StudentEnteredEvent) => void
'student-left': (data: StudentLeftEvent) => void
+ // Player presence events (player channel)
+ 'presence-removed': (data: PresenceRemovedEvent) => void
+
// Session observation events
'practice-state': (data: PracticeStateEvent) => void
'tutorial-state': (data: TutorialStateEvent) => void
@@ -128,6 +140,8 @@ export interface ClassroomClientToServerEvents {
// Channel subscriptions
'join-classroom': (data: { classroomId: string }) => void
'leave-classroom': (data: { classroomId: string }) => void
+ 'join-player': (data: { playerId: string }) => void
+ 'leave-player': (data: { playerId: string }) => void
'observe-session': (data: { sessionId: string; observerId: string }) => void
'stop-observing': (data: { sessionId: string }) => void
diff --git a/apps/web/src/lib/queryKeys.ts b/apps/web/src/lib/queryKeys.ts
index 1a30eb89..2bc788cb 100644
--- a/apps/web/src/lib/queryKeys.ts
+++ b/apps/web/src/lib/queryKeys.ts
@@ -43,4 +43,5 @@ export const classroomKeys = {
detail: (id: string) => [...classroomKeys.all, 'detail', id] as const,
enrollments: (id: string) => [...classroomKeys.all, 'enrollments', id] as const,
presence: (id: string) => [...classroomKeys.all, 'presence', id] as const,
+ pendingParentApprovals: () => [...classroomKeys.all, 'pendingParentApprovals'] as const,
}
diff --git a/apps/web/src/lib/viewer.ts b/apps/web/src/lib/viewer.ts
index 218b4637..3c6fd4a7 100644
--- a/apps/web/src/lib/viewer.ts
+++ b/apps/web/src/lib/viewer.ts
@@ -1,5 +1,7 @@
+import { eq } from 'drizzle-orm'
import { cookies, headers } from 'next/headers'
import { auth } from '@/auth'
+import { db, schema } from '@/db'
import { GUEST_COOKIE_NAME, verifyGuestToken } from './guest-token'
/**
@@ -65,3 +67,89 @@ export async function getViewerId(): Promise {
throw new Error('No valid viewer session found')
}
}
+
+/**
+ * Get or create a user record from a guestId
+ *
+ * This is the core function for converting a guest session identifier
+ * into a database user record. If no user exists for the guestId,
+ * one is created automatically.
+ *
+ * @param guestId - The guest session identifier
+ * @returns The user record from the database
+ */
+async function getOrCreateUserFromGuestId(guestId: string) {
+ let user = await db.query.users.findFirst({
+ where: eq(schema.users.guestId, guestId),
+ })
+
+ if (!user) {
+ const [newUser] = await db.insert(schema.users).values({ guestId }).returning()
+ user = newUser
+ }
+
+ return user
+}
+
+/**
+ * Get the database user.id for the current viewer
+ *
+ * IMPORTANT: This returns the actual database user.id, NOT the guestId.
+ * Use this when you need to pass a user ID to authorization functions
+ * like canPerformAction(), or any function that expects a database user.id.
+ *
+ * For authenticated users: returns session.user.id directly
+ * For guests: looks up or creates the user record by guestId, returns user.id
+ * For unknown: throws an error
+ *
+ * @throws Error if no valid viewer found
+ */
+export async function getDbUserId(): Promise {
+ const viewer = await getViewer()
+
+ switch (viewer.kind) {
+ case 'user':
+ // Authenticated users already have a database user.id in their session
+ return viewer.session.user!.id
+ case 'guest': {
+ // Guests need to look up their user record by guestId
+ const user = await getOrCreateUserFromGuestId(viewer.guestId)
+ return user.id
+ }
+ case 'unknown':
+ throw new Error('No valid viewer session found')
+ }
+}
+
+/**
+ * Get the full user record for the current viewer
+ *
+ * This returns the complete database user record, useful when you need
+ * more than just the user.id (e.g., for checking user properties).
+ *
+ * For authenticated users: looks up user by session.user.id
+ * For guests: looks up or creates user by guestId
+ * For unknown: throws an error
+ *
+ * @throws Error if no valid viewer found
+ */
+export async function getViewerUser() {
+ const viewer = await getViewer()
+
+ switch (viewer.kind) {
+ case 'user': {
+ const user = await db.query.users.findFirst({
+ where: eq(schema.users.id, viewer.session.user!.id),
+ })
+ if (!user) {
+ throw new Error('Authenticated user not found in database')
+ }
+ return user
+ }
+ case 'guest': {
+ return getOrCreateUserFromGuestId(viewer.guestId)
+ }
+ case 'unknown':
+ throw new Error('No valid viewer session found')
+ }
+}
diff --git a/apps/web/src/socket-server.ts b/apps/web/src/socket-server.ts
index e6cbf502..d1227aa0 100644
--- a/apps/web/src/socket-server.ts
+++ b/apps/web/src/socket-server.ts
@@ -740,6 +740,26 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}
})
+ // Player: Join player channel (for students to receive their own presence updates)
+ socket.on('join-player', async ({ playerId }: { playerId: string }) => {
+ try {
+ await socket.join(`player:${playerId}`)
+ console.log(`👤 User joined player channel: ${playerId}`)
+ } catch (error) {
+ console.error('Error joining player channel:', error)
+ }
+ })
+
+ // Player: Leave player channel
+ socket.on('leave-player', async ({ playerId }: { playerId: string }) => {
+ try {
+ await socket.leave(`player:${playerId}`)
+ console.log(`👤 User left player channel: ${playerId}`)
+ } catch (error) {
+ console.error('Error leaving player channel:', error)
+ }
+ })
+
// Session Observation: Start observing a practice session
socket.on(
'observe-session',