feat: enable parents to observe children's practice sessions

- Add GET /api/players/[playerId]/active-session endpoint
- Add useChildActiveSession and useChildrenActiveSessions hooks
- Update useUnifiedStudents to fetch child session status
- Parents now see "Watch Session" button when child is practicing

Polls every 10 seconds to detect session start/end.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-27 19:52:37 -06:00
parent d6e369f9dc
commit 7b82995664
4 changed files with 175 additions and 4 deletions

View File

@ -0,0 +1,79 @@
import { NextResponse } from 'next/server'
import { and, eq, inArray } from 'drizzle-orm'
import { db } from '@/db'
import { sessionPlans, type SessionPart, type SlotResult } from '@/db/schema/session-plans'
import { canPerformAction } from '@/lib/classroom'
import { getDbUserId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ playerId: string }>
}
/**
* GET /api/players/[playerId]/active-session
*
* Returns the active session for a player (if any).
* Requires 'view' permission (parent or teacher relationship).
*
* Response:
* - { session: null } if no active session
* - { session: { sessionId, status, completedProblems, totalProblems } } if active
*/
export async function GET(_request: Request, { params }: RouteParams) {
try {
const { playerId } = await params
if (!playerId) {
return NextResponse.json({ error: 'Player ID required' }, { status: 400 })
}
// Authorization: require 'view' permission (parent or teacher)
const userId = await getDbUserId()
const canView = await canPerformAction(userId, playerId, 'view')
if (!canView) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Find active session (started but not completed/abandoned)
const activeStatuses = ['approved', 'in_progress'] as const
const activePlan = await db
.select({
id: sessionPlans.id,
status: sessionPlans.status,
parts: sessionPlans.parts,
results: sessionPlans.results,
})
.from(sessionPlans)
.where(
and(
eq(sessionPlans.playerId, playerId),
inArray(sessionPlans.status, [...activeStatuses])
)
)
.orderBy(sessionPlans.createdAt)
.limit(1)
.then((rows) => rows[0])
if (!activePlan) {
return NextResponse.json({ session: null })
}
// Calculate progress - parts is an array of SessionPart, each with slots
const parts = (activePlan.parts as SessionPart[]) || []
const results = (activePlan.results as SlotResult[]) || []
const totalProblems = parts.reduce((sum, part) => sum + part.slots.length, 0)
const completedProblems = results.length
return NextResponse.json({
session: {
sessionId: activePlan.id,
status: activePlan.status,
completedProblems,
totalProblems,
},
})
} catch (error) {
console.error('Error fetching active session:', error)
return NextResponse.json({ error: 'Failed to fetch active session' }, { status: 500 })
}
}

View File

@ -9,7 +9,11 @@ import {
type ActiveSessionInfo,
type PresenceStudent,
} from '@/hooks/useClassroom'
import { usePlayersWithSkillData } from '@/hooks/useUserPlayers'
import {
usePlayersWithSkillData,
useChildrenActiveSessions,
type ChildActiveSession,
} from '@/hooks/useUserPlayers'
import type { StudentWithSkillData } from '@/utils/studentGrouping'
import type { UnifiedStudent, StudentRelationship, StudentActivity } from '@/types/student'
@ -52,6 +56,11 @@ export function useUnifiedStudents(
initialData: initialPlayers,
})
// Get active sessions for my children (polls every 10s)
const childIds = useMemo(() => myChildren.map((c) => c.id), [myChildren])
const { sessionMap: childSessionMap, isLoading: isLoadingChildSessions } =
useChildrenActiveSessions(childIds)
// Get enrolled students (teachers only)
const { data: enrolledStudents = [], isLoading: isLoadingEnrolled } = useEnrolledStudents(
classroom?.id
@ -62,7 +71,7 @@ export function useUnifiedStudents(
classroom?.id
)
// Get active sessions (teachers only)
// Get active sessions in classroom (teachers only)
const { data: activeSessions = [], isLoading: isLoadingActiveSessions } =
useActiveSessionsInClassroom(classroom?.id)
@ -100,7 +109,11 @@ export function useUnifiedStudents(
enrollmentStatus: null, // TODO: Add pending enrollment lookup
}
const session = sessionMap.get(child.id)
// Check both classroom sessions (if teacher) and child-specific sessions (for parents)
const classroomSession = sessionMap.get(child.id)
const childSession = childSessionMap.get(child.id)
const session = classroomSession || childSession
const activity: StudentActivity = session
? {
status: 'practicing',
@ -191,11 +204,13 @@ export function useUnifiedStudents(
enrolledIds,
presenceMap,
sessionMap,
childSessionMap,
])
const isLoading =
isLoadingClassroom ||
isLoadingChildren ||
isLoadingChildSessions ||
(isTeacher && (isLoadingEnrolled || isLoadingPresence || isLoadingActiveSessions))
return {

View File

@ -1,6 +1,12 @@
'use client'
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import {
useMutation,
useQueries,
useQuery,
useQueryClient,
useSuspenseQuery,
} from '@tanstack/react-query'
import type { Player } from '@/db/schema/players'
import { api } from '@/lib/queryClient'
import { playerKeys } from '@/lib/queryKeys'
@ -333,3 +339,73 @@ export function useLinkChild() {
},
})
}
/**
* Active session info for a child
*/
export interface ChildActiveSession {
sessionId: string
status: string
completedProblems: number
totalProblems: number
}
/**
* Fetch active session for a player
*/
async function fetchActiveSession(playerId: string): Promise<ChildActiveSession | null> {
const res = await api(`players/${playerId}/active-session`)
if (!res.ok) {
// 403 means not authorized, just return null
if (res.status === 403) return null
throw new Error('Failed to fetch active session')
}
const data = await res.json()
return data.session
}
/**
* Hook: Fetch active session for a single child
*
* Polls every 10 seconds to detect when sessions start/end.
* Returns null if no active session or not authorized.
*/
export function useChildActiveSession(playerId: string | undefined) {
return useQuery({
queryKey: playerKeys.activeSession(playerId ?? ''),
queryFn: () => fetchActiveSession(playerId!),
enabled: !!playerId,
refetchInterval: 10_000, // Poll every 10 seconds
staleTime: 5_000, // Consider stale after 5 seconds
})
}
/**
* Hook: Fetch active sessions for multiple children
*
* Returns a map of playerId -> session info.
* Uses individual queries under the hood for proper caching.
*/
export function useChildrenActiveSessions(playerIds: string[]) {
const queries = useQueries({
queries: playerIds.map((playerId) => ({
queryKey: playerKeys.activeSession(playerId),
queryFn: () => fetchActiveSession(playerId),
refetchInterval: 10_000,
staleTime: 5_000,
})),
})
// Build a map from the results
const sessionMap = new Map<string, ChildActiveSession>()
for (let i = 0; i < playerIds.length; i++) {
const session = queries[i].data
if (session) {
sessionMap.set(playerIds[i], session)
}
}
const isLoading = queries.some((q) => q.isLoading)
return { sessionMap, isLoading }
}

View File

@ -15,6 +15,7 @@ export const playerKeys = {
enrolledClassrooms: (playerId: string) =>
[...playerKeys.all, playerId, 'enrolled-classrooms'] as const,
presence: (playerId: string) => [...playerKeys.all, playerId, 'presence'] as const,
activeSession: (playerId: string) => [...playerKeys.all, playerId, 'active-session'] as const,
}
// Curriculum query keys