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:
parent
d6e369f9dc
commit
7b82995664
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue