feat(practice): parent session observation + relationship UI + error boundaries

- Fix parent authorization for session observation (use userId not viewerId)
- Move SessionObserverModal from NotesModal to parent components to fix z-index
- Add session observation support to student dashboard
- Add PracticeErrorBoundary to dashboard for tighter error handling
- Add RelationshipBadge, RelationshipCard, RelationshipIndicator components
- Add stakeholders API endpoint and useStudentStakeholders hook
- Integrate relationship info into PracticeSubNav and StudentSelector
- Add hover cards for relationship details on student tiles

🤖 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-28 10:28:59 -06:00
parent c631e10728
commit 07484fdfac
33 changed files with 4059 additions and 957 deletions

View File

@@ -116,13 +116,9 @@
"abacus_settings_user_id_users_id_fk": {
"name": "abacus_settings_user_id_users_id_fk",
"tableFrom": "abacus_settings",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -240,9 +236,7 @@
"indexes": {
"arcade_rooms_code_unique": {
"name": "arcade_rooms_code_unique",
"columns": [
"code"
],
"columns": ["code"],
"isUnique": true
}
},
@@ -339,26 +333,18 @@
"arcade_sessions_room_id_arcade_rooms_id_fk": {
"name": "arcade_sessions_room_id_arcade_rooms_id_fk",
"tableFrom": "arcade_sessions",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"arcade_sessions_user_id_users_id_fk": {
"name": "arcade_sessions_user_id_users_id_fk",
"tableFrom": "arcade_sessions",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -424,9 +410,7 @@
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": [
"user_id"
],
"columns": ["user_id"],
"isUnique": false
}
},
@@ -434,13 +418,9 @@
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -514,9 +494,7 @@
"indexes": {
"idx_room_members_user_id_unique": {
"name": "idx_room_members_user_id_unique",
"columns": [
"user_id"
],
"columns": ["user_id"],
"isUnique": true
}
},
@@ -524,13 +502,9 @@
"room_members_room_id_arcade_rooms_id_fk": {
"name": "room_members_room_id_arcade_rooms_id_fk",
"tableFrom": "room_members",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -605,13 +579,9 @@
"room_member_history_room_id_arcade_rooms_id_fk": {
"name": "room_member_history_room_id_arcade_rooms_id_fk",
"tableFrom": "room_member_history",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -713,10 +683,7 @@
"indexes": {
"idx_room_invitations_user_room": {
"name": "idx_room_invitations_user_room",
"columns": [
"user_id",
"room_id"
],
"columns": ["user_id", "room_id"],
"isUnique": true
}
},
@@ -724,13 +691,9 @@
"room_invitations_room_id_arcade_rooms_id_fk": {
"name": "room_invitations_room_id_arcade_rooms_id_fk",
"tableFrom": "room_invitations",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -833,13 +796,9 @@
"room_reports_room_id_arcade_rooms_id_fk": {
"name": "room_reports_room_id_arcade_rooms_id_fk",
"tableFrom": "room_reports",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -918,10 +877,7 @@
"indexes": {
"idx_room_bans_user_room": {
"name": "idx_room_bans_user_room",
"columns": [
"user_id",
"room_id"
],
"columns": ["user_id", "room_id"],
"isUnique": true
}
},
@@ -929,13 +885,9 @@
"room_bans_room_id_arcade_rooms_id_fk": {
"name": "room_bans_room_id_arcade_rooms_id_fk",
"tableFrom": "room_bans",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -998,13 +950,9 @@
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -1062,16 +1010,12 @@
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": [
"guest_id"
],
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"columns": ["email"],
"isUnique": true
}
},
@@ -1091,4 +1035,4 @@
"internal": {
"indexes": {}
}
}
}

View File

@@ -325,4 +325,4 @@
"breakpoints": true
}
]
}
}

View File

@@ -24,15 +24,23 @@ test.describe('API Authorization', () => {
const createPlayerRes = await request.post('/api/players', {
data: { name: 'Test Child', emoji: '🧒', color: '#4CAF50' },
})
expect(createPlayerRes.ok(), `Create player failed: ${await createPlayerRes.text()}`).toBeTruthy()
expect(
createPlayerRes.ok(),
`Create player failed: ${await createPlayerRes.text()}`
).toBeTruthy()
const { player } = await createPlayerRes.json()
const playerId = player.id
// Step 1.5: Enable skills for this player (required before creating session plan)
const enableSkillsRes = await request.put(`/api/curriculum/${playerId}/skills`, {
data: { masteredSkillIds: ['1a-direct-addition', '1b-heaven-bead', '1c-simple-combinations'] },
data: {
masteredSkillIds: ['1a-direct-addition', '1b-heaven-bead', '1c-simple-combinations'],
},
})
expect(enableSkillsRes.ok(), `Enable skills failed: ${await enableSkillsRes.text()}`).toBeTruthy()
expect(
enableSkillsRes.ok(),
`Enable skills failed: ${await enableSkillsRes.text()}`
).toBeTruthy()
// Step 2: Create a session plan
const createPlanRes = await request.post(`/api/curriculum/${playerId}/sessions/plans`, {
@@ -43,9 +51,12 @@ test.describe('API Authorization', () => {
const planId = plan.id
// Step 3: Approve the plan (PATCH - was vulnerable)
const approveRes = await request.patch(`/api/curriculum/${playerId}/sessions/plans/${planId}`, {
data: { action: 'approve' },
})
const approveRes = await request.patch(
`/api/curriculum/${playerId}/sessions/plans/${planId}`,
{
data: { action: 'approve' },
}
)
expect(approveRes.ok(), `Approve failed: ${await approveRes.text()}`).toBeTruthy()
// Step 4: Start the plan
@@ -55,9 +66,12 @@ test.describe('API Authorization', () => {
expect(startRes.ok(), `Start failed: ${await startRes.text()}`).toBeTruthy()
// Step 5: Abandon the plan (cleanup)
const abandonRes = await request.patch(`/api/curriculum/${playerId}/sessions/plans/${planId}`, {
data: { action: 'abandon' },
})
const abandonRes = await request.patch(
`/api/curriculum/${playerId}/sessions/plans/${planId}`,
{
data: { action: 'abandon' },
}
)
expect(abandonRes.ok(), `Abandon failed: ${await abandonRes.text()}`).toBeTruthy()
// Cleanup: Delete the player
@@ -96,17 +110,23 @@ test.describe('API Authorization', () => {
})
expect(enableSkillsRes.ok()).toBeTruthy()
const createPlanRes = await userARequest.post(`/api/curriculum/${playerId}/sessions/plans`, {
data: { durationMinutes: 5 },
})
const createPlanRes = await userARequest.post(
`/api/curriculum/${playerId}/sessions/plans`,
{
data: { durationMinutes: 5 },
}
)
expect(createPlanRes.ok()).toBeTruthy()
const { plan } = await createPlanRes.json()
const planId = plan.id
// User B: Try to modify User A's session plan (should fail with 403)
const attackRes = await userBRequest.patch(`/api/curriculum/${playerId}/sessions/plans/${planId}`, {
data: { action: 'abandon' },
})
const attackRes = await userBRequest.patch(
`/api/curriculum/${playerId}/sessions/plans/${planId}`,
{
data: { action: 'abandon' },
}
)
expect(attackRes.status()).toBe(403)
const errorBody = await attackRes.json()
expect(errorBody.error).toBe('Not authorized')
@@ -144,7 +164,10 @@ test.describe('API Authorization', () => {
const setMasteredRes = await request.put(`/api/curriculum/${playerId}/skills`, {
data: { masteredSkillIds: ['1a-direct-addition'] },
})
expect(setMasteredRes.ok(), `Set mastered failed: ${await setMasteredRes.text()}`).toBeTruthy()
expect(
setMasteredRes.ok(),
`Set mastered failed: ${await setMasteredRes.text()}`
).toBeTruthy()
// PATCH: Refresh skill recency
const refreshRes = await request.patch(`/api/curriculum/${playerId}/skills`, {
@@ -289,7 +312,9 @@ test.describe('API Authorization', () => {
}
})
test('cannot record game stats for mixed authorized/unauthorized players', async ({ browser }) => {
test('cannot record game stats for mixed authorized/unauthorized players', async ({
browser,
}) => {
const userAContext = await browser.newContext()
const userBContext = await browser.newContext()

View File

@@ -4,7 +4,12 @@ import { db } from '@/db'
import { players } from '@/db/schema'
import type { SessionPlan, SlotResult } from '@/db/schema/session-plans'
import { canPerformAction, getStudentPresence } from '@/lib/classroom'
import { emitSessionEnded, emitSessionStarted } from '@/lib/classroom/socket-emitter'
import {
emitSessionEnded,
emitSessionEndedToPlayer,
emitSessionStarted,
emitSessionStartedToPlayer,
} from '@/lib/classroom/socket-emitter'
import {
abandonSessionPlan,
approveSessionPlan,
@@ -84,8 +89,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
case 'start':
plan = await startSessionPlan(planId)
// Emit session started event if student is in a classroom
await emitSessionEventIfPresent(playerId, planId, 'start')
// Emit session events to player channel (parents) and classroom channel (if present)
await emitSessionEvents(playerId, planId, 'start')
break
case 'record':
@@ -100,14 +105,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
case 'end_early':
plan = await completeSessionPlanEarly(planId, reason)
// Emit session ended event if student is in a classroom
await emitSessionEventIfPresent(playerId, planId, 'end_early')
// Emit session events to player channel (parents) and classroom channel (if present)
await emitSessionEvents(playerId, planId, 'end_early')
break
case 'abandon':
plan = await abandonSessionPlan(planId)
// Emit session ended event if student is in a classroom
await emitSessionEventIfPresent(playerId, planId, 'abandon')
// Emit session events to player channel (parents) and classroom channel (if present)
await emitSessionEvents(playerId, planId, 'abandon')
break
default:
@@ -127,31 +132,40 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
}
/**
* Helper to emit session socket events if the student is present in a classroom
* Helper to emit session socket events to both:
* 1. Player channel (for parent observation) - ALWAYS
* 2. Classroom channel (for teacher observation) - only if student is present
*/
async function emitSessionEventIfPresent(
async function emitSessionEvents(
playerId: string,
sessionId: string,
action: 'start' | 'end_early' | 'abandon'
): Promise<void> {
try {
// Check if student is in a classroom
const presence = await getStudentPresence(playerId)
if (!presence) return
// Get player name
const player = await db.query.players.findFirst({
where: eq(players.id, playerId),
})
const playerName = player?.name ?? 'Unknown'
const classroomId = presence.classroomId
// Always emit to player channel (for parents)
if (action === 'start') {
await emitSessionStarted({ sessionId, playerId, playerName }, classroomId)
await emitSessionStartedToPlayer({ sessionId, playerId, playerName })
} else {
const reason = action === 'end_early' ? 'ended_early' : 'abandoned'
await emitSessionEnded({ sessionId, playerId, playerName, reason }, classroomId)
await emitSessionEndedToPlayer({ sessionId, playerId, playerName, reason })
}
// Also emit to classroom channel if student is present
const presence = await getStudentPresence(playerId)
if (presence) {
const classroomId = presence.classroomId
if (action === 'start') {
await emitSessionStarted({ sessionId, playerId, playerName }, classroomId)
} else {
const reason = action === 'end_early' ? 'ended_early' : 'abandoned'
await emitSessionEnded({ sessionId, playerId, playerName, reason }, classroomId)
}
}
} catch (error) {
// Don't fail the request if socket emission fails

View File

@@ -6,11 +6,11 @@ import { canPerformAction } from '@/lib/classroom'
import { getDbUserId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ playerId: string }>
params: Promise<{ id: string }>
}
/**
* GET /api/players/[playerId]/active-session
* GET /api/players/[id]/active-session
*
* Returns the active session for a player (if any).
* Requires 'view' permission (parent or teacher relationship).
@@ -21,7 +21,7 @@ interface RouteParams {
*/
export async function GET(_request: Request, { params }: RouteParams) {
try {
const { playerId } = await params
const { id: playerId } = await params
if (!playerId) {
return NextResponse.json({ error: 'Player ID required' }, { status: 400 })
@@ -45,10 +45,7 @@ export async function GET(_request: Request, { params }: RouteParams) {
})
.from(sessionPlans)
.where(
and(
eq(sessionPlans.playerId, playerId),
inArray(sessionPlans.status, [...activeStatuses])
)
and(eq(sessionPlans.playerId, playerId), inArray(sessionPlans.status, [...activeStatuses]))
)
.orderBy(sessionPlans.createdAt)
.limit(1)

View File

@@ -0,0 +1,201 @@
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db } from '@/db'
import { classrooms, enrollmentRequests, users } from '@/db/schema'
import {
canPerformAction,
getEnrolledClassrooms,
getLinkedParents,
getStudentPresence,
getTeacherClassroom,
} from '@/lib/classroom'
import { getDbUserId } from '@/lib/viewer'
import type {
EnrolledClassroomInfo,
ParentInfo,
PendingEnrollmentInfo,
PresenceInfo,
StudentStakeholders,
ViewerRelationshipSummary,
ViewerRelationType,
} from '@/types/student'
interface RouteParams {
params: Promise<{ id: string }>
}
/**
* GET /api/players/[id]/stakeholders
*
* Get complete stakeholder information for a student:
* - All linked parents
* - All enrolled classrooms (with teacher names)
* - Pending enrollment requests
* - Current classroom presence
* - Viewer's relationship summary
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { id: playerId } = await params
const viewerId = await getDbUserId()
// Check authorization: must have at least view access
const canView = await canPerformAction(viewerId, playerId, 'view')
if (!canView) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Fetch all data in parallel
const [linkedParents, enrolledClassroomsList, presence, viewerClassroom, pendingRequests] =
await Promise.all([
getLinkedParents(playerId),
getEnrolledClassrooms(playerId),
getStudentPresence(playerId),
getTeacherClassroom(viewerId),
db.query.enrollmentRequests.findMany({
where: and(
eq(enrollmentRequests.playerId, playerId),
eq(enrollmentRequests.status, 'pending')
),
}),
])
// Get teacher info for enrolled classrooms
const teacherIds = enrolledClassroomsList.map((c) => c.teacherId)
const uniqueTeacherIds = [...new Set(teacherIds)]
const teachers =
uniqueTeacherIds.length > 0
? await db.query.users.findMany({
where: inArray(users.id, uniqueTeacherIds),
})
: []
const teacherMap = new Map(teachers.map((t) => [t.id, t]))
// Get classroom info for pending requests
const pendingClassroomIds = pendingRequests.map((r) => r.classroomId)
const uniquePendingClassroomIds = [...new Set(pendingClassroomIds)]
const pendingClassrooms =
uniquePendingClassroomIds.length > 0
? await db.query.classrooms.findMany({
where: inArray(classrooms.id, uniquePendingClassroomIds),
})
: []
const pendingClassroomMap = new Map(pendingClassrooms.map((c) => [c.id, c]))
// Get teacher info for pending request classrooms
const pendingTeacherIds = pendingClassrooms.map((c) => c.teacherId)
const uniquePendingTeacherIds = [...new Set(pendingTeacherIds)]
const additionalTeachers =
uniquePendingTeacherIds.length > 0
? await db.query.users.findMany({
where: inArray(users.id, uniquePendingTeacherIds),
})
: []
for (const t of additionalTeachers) {
if (!teacherMap.has(t.id)) {
teacherMap.set(t.id, t)
}
}
// Build parent info
const parents: ParentInfo[] = linkedParents.map((parent) => ({
id: parent.id,
name: parent.name ?? 'Unknown',
email: parent.email ?? undefined,
isMe: parent.id === viewerId,
}))
// Build enrolled classrooms info
const enrolledClassrooms: EnrolledClassroomInfo[] = enrolledClassroomsList.map((classroom) => {
const teacher = teacherMap.get(classroom.teacherId)
return {
id: classroom.id,
name: classroom.name,
teacherName: teacher?.name ?? 'Unknown Teacher',
isMyClassroom: classroom.teacherId === viewerId,
}
})
// Build pending enrollments info
const pendingEnrollments: PendingEnrollmentInfo[] = pendingRequests.map((request) => {
const classroom = pendingClassroomMap.get(request.classroomId)
const teacher = classroom ? teacherMap.get(classroom.teacherId) : null
return {
id: request.id,
classroomId: request.classroomId,
classroomName: classroom?.name ?? 'Unknown Classroom',
teacherName: teacher?.name ?? 'Unknown Teacher',
pendingApproval: request.teacherApproval === null ? 'teacher' : 'parent',
initiatedBy: request.requestedByRole as 'teacher' | 'parent',
}
})
// Build presence info
let currentPresence: PresenceInfo | null = null
if (presence?.classroomId) {
const presenceClassroom = await db.query.classrooms.findFirst({
where: eq(classrooms.id, presence.classroomId),
})
if (presenceClassroom) {
const presenceTeacher = teacherMap.get(presenceClassroom.teacherId)
if (!presenceTeacher) {
const fetchedTeacher = await db.query.users.findFirst({
where: eq(users.id, presenceClassroom.teacherId),
})
if (fetchedTeacher) {
teacherMap.set(fetchedTeacher.id, fetchedTeacher)
}
}
const teacher = teacherMap.get(presenceClassroom.teacherId)
currentPresence = {
classroomId: presenceClassroom.id,
classroomName: presenceClassroom.name,
teacherName: teacher?.name ?? 'Unknown Teacher',
}
}
}
// Determine viewer's relationship
const isMyChild = parents.some((p) => p.isMe)
const isMyStudent = enrolledClassrooms.some((c) => c.isMyClassroom)
const isPresent = currentPresence && viewerClassroom?.id === currentPresence.classroomId
let viewerType: ViewerRelationType = 'none'
let viewerDescription = 'No relationship'
let viewerClassroomName: string | undefined
if (isMyChild) {
viewerType = 'parent'
viewerDescription = 'Your child'
} else if (isMyStudent) {
viewerType = 'teacher'
const myClassroom = enrolledClassrooms.find((c) => c.isMyClassroom)
viewerClassroomName = myClassroom?.name
viewerDescription = `Enrolled in ${viewerClassroomName ?? 'your classroom'}`
} else if (isPresent) {
viewerType = 'observer'
viewerDescription = `Visiting ${currentPresence?.classroomName ?? 'your classroom'}`
}
const viewerRelationship: ViewerRelationshipSummary = {
type: viewerType,
description: viewerDescription,
classroomName: viewerClassroomName,
}
const stakeholders: StudentStakeholders = {
parents,
enrolledClassrooms,
pendingEnrollments,
currentPresence,
}
return NextResponse.json({
stakeholders,
viewerRelationship,
})
} catch (error) {
console.error('Failed to fetch stakeholders:', error)
return NextResponse.json({ error: 'Failed to fetch stakeholders' }, { status: 500 })
}
}

View File

@@ -70,14 +70,33 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
isTeacher,
classroomCode,
classroomId,
} = useUnifiedStudents(initialPlayers)
} = useUnifiedStudents(initialPlayers, userId)
// Real-time WebSocket updates for classroom events
// This invalidates React Query caches when students enter/leave, sessions start/end, etc.
useClassroomSocket(classroomId)
// View and filter state
const availableViews = useMemo(() => getAvailableViews(isTeacher), [isTeacher])
// Use unified students (already fetched above) as the main data source
// Cast to maintain compatibility with existing grouping functions
const players = unifiedStudents as StudentWithSkillData[]
// Mutation for bulk updates
const updatePlayer = useUpdatePlayer()
// Count archived students
const archivedCount = useMemo(() => players.filter((p) => p.isArchived).length, [players])
// Compute view counts from unified students (must be before availableViews)
const viewCounts = useMemo(
() => computeViewCounts(unifiedStudents, isTeacher),
[unifiedStudents, isTeacher]
)
// View and filter state - pass viewCounts so active sub-views appear conditionally
const availableViews = useMemo(
() => getAvailableViews(isTeacher, viewCounts),
[isTeacher, viewCounts]
)
const defaultView = useMemo(() => getDefaultView(isTeacher), [isTeacher])
const [currentView, setCurrentView] = useState<StudentView>(defaultView)
const [searchQuery, setSearchQuery] = useState('')
@@ -94,22 +113,6 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
// Session observation state
const [observingStudent, setObservingStudent] = useState<UnifiedStudent | null>(null)
// Use unified students (already fetched above) as the main data source
// Cast to maintain compatibility with existing grouping functions
const players = unifiedStudents as StudentWithSkillData[]
// Mutation for bulk updates
const updatePlayer = useUpdatePlayer()
// Count archived students
const archivedCount = useMemo(() => players.filter((p) => p.isArchived).length, [players])
// Compute view counts from unified students
const viewCounts = useMemo(
() => computeViewCounts(unifiedStudents, isTeacher),
[unifiedStudents, isTeacher]
)
// Filter students by view first, then apply search/skill filters
const viewFilteredStudents = useMemo(
() => filterStudentsByView(unifiedStudents, currentView),
@@ -334,69 +337,42 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
padding: '2rem',
})}
>
{/* Header */}
<header
className={css({
textAlign: 'center',
marginBottom: '2rem',
})}
>
<h1
{/* Teacher option */}
{!isLoadingClassroom && !classroom && (
<div
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '0.5rem',
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
gap: '12px',
marginBottom: '1rem',
})}
>
Daily Practice
</h1>
<p
className={css({
fontSize: '1rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Build your soroban skills one step at a time
</p>
{/* Teacher option */}
{!isLoadingClassroom && !classroom && (
<div
{/* Become a Teacher option */}
<button
type="button"
onClick={handleBecomeTeacher}
data-action="become-teacher"
className={css({
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
gap: '12px',
marginTop: '16px',
padding: '8px 16px',
backgroundColor: 'transparent',
color: isDark ? 'blue.400' : 'blue.600',
border: '1px solid',
borderColor: isDark ? 'blue.700' : 'blue.300',
borderRadius: '8px',
fontSize: '0.875rem',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
borderColor: isDark ? 'blue.500' : 'blue.400',
},
})}
>
{/* Become a Teacher option */}
<button
type="button"
onClick={handleBecomeTeacher}
data-action="become-teacher"
className={css({
padding: '8px 16px',
backgroundColor: 'transparent',
color: isDark ? 'blue.400' : 'blue.600',
border: '1px solid',
borderColor: isDark ? 'blue.700' : 'blue.300',
borderRadius: '8px',
fontSize: '0.875rem',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
borderColor: isDark ? 'blue.500' : 'blue.400',
},
})}
>
🏫 Are you a teacher? Create a classroom
</button>
</div>
)}
</header>
🏫 Are you a teacher? Create a classroom
</button>
</div>
)}
{/* Teacher Enrollment Requests - for teachers to approve parent-initiated requests */}
{isTeacher && classroomId && <TeacherEnrollmentSection classroomId={classroomId} />}
@@ -680,7 +656,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
emoji: observingStudent.emoji,
color: observingStudent.color,
}}
observerId={viewerId}
observerId={userId}
/>
)}
</PageWithNav>

View File

@@ -3,20 +3,20 @@
import { useQuery } from '@tanstack/react-query'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useEnrolledClassrooms, useMyClassroom } from '@/hooks/useClassroom'
import { SessionObserverModal } from '@/components/classroom'
import { PageWithNav } from '@/components/PageWithNav'
import { useIncomingTransition } from '@/contexts/PageTransitionContext'
import {
type ActiveSessionState,
type CurrentPhaseInfo,
getSkillClassification,
PracticeErrorBoundary,
PracticeSubNav,
ProgressDashboard,
type SkillClassification,
type SkillHealthSummary,
SkillProgressChart,
StartPracticeModal,
StudentActionMenu,
type StudentWithProgress,
VirtualizedSessionList,
} from '@/components/practice'
@@ -82,6 +82,8 @@ interface DashboardClientProps {
currentPracticingSkillIds: string[]
problemHistory: ProblemResultWithContext[]
initialTab?: TabId
/** Database user ID for session observation authorization */
userId: string
}
/** Processed skill with computed metrics (for Skills tab) */
@@ -2495,6 +2497,7 @@ export function DashboardClient({
currentPracticingSkillIds,
problemHistory,
initialTab = 'overview',
userId,
}: DashboardClientProps) {
const router = useRouter()
const searchParams = useSearchParams()
@@ -2530,35 +2533,6 @@ export function DashboardClient({
// This ensures the UI updates when teacher removes student from classroom
usePlayerPresenceSocket(studentId)
// Classroom context for student actions
const { data: classroom } = useMyClassroom()
const { data: enrolledClassrooms = [] } = useEnrolledClassrooms(studentId)
const isEnrolled = enrolledClassrooms.length > 0
// Build StudentActionData with same structure as student tiles/quick look
const studentActionData = useMemo(
() => ({
id: player.id,
name: player.name,
isArchived: player.isArchived,
relationship: {
isMyChild: true, // On this student's dashboard, they're either your child or enrolled student you manage
isEnrolled,
isPresent: false, // TODO: Could check classroom presence if needed
enrollmentStatus: null,
},
activity: activeSession
? {
status: 'practicing' as const,
sessionId: activeSession.id,
}
: {
status: 'idle' as const,
},
}),
[player.id, player.name, player.isArchived, isEnrolled, activeSession]
)
// Handle incoming page transition (from QuickLook modal)
const { hasTransition, isRevealing, signalReady } = useIncomingTransition()
@@ -2595,6 +2569,17 @@ export function DashboardClient({
// Tab state - sync with URL
const [activeTab, setActiveTab] = useState<TabId>(initialTab)
// Session observer state
const [isObserving, setIsObserving] = useState(false)
// Handle session observation from PracticeSubNav action menu
const handleObserveSession = useCallback((sessionId: string) => {
// We're already on this student's page, just open the observer modal
if (activeSession?.id === sessionId) {
setIsObserving(true)
}
}, [activeSession?.id])
const handleTabChange = useCallback(
(tab: TabId) => {
setActiveTab(tab)
@@ -2760,10 +2745,15 @@ export function DashboardClient({
{/* Single ProjectingBanner renders at provider level */}
<ProjectingBanner />
<PageWithNav>
<PracticeSubNav student={selectedStudent} pageContext="dashboard" />
<PracticeSubNav
student={selectedStudent}
pageContext="dashboard"
onObserveSession={handleObserveSession}
/>
<main
data-component="practice-dashboard-page"
<PracticeErrorBoundary studentName={player.name}>
<main
data-component="practice-dashboard-page"
style={{
opacity: contentOpacity,
transition: contentTransition,
@@ -2775,19 +2765,6 @@ export function DashboardClient({
})}
>
<div className={css({ maxWidth: '900px', margin: '0 auto' })}>
{/* Student actions & classroom presence */}
<div
className={css({
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
gap: '8px',
marginBottom: '0.75rem',
})}
>
<StudentActionMenu student={studentActionData} variant="inline" />
</div>
{/* Session mode banner - renders in-flow, projects to nav on scroll */}
<ContentBannerSlot
stickyOffset={STICKY_HEADER_OFFSET}
@@ -2855,6 +2832,37 @@ export function DashboardClient({
onStarted={() => setShowStartPracticeModal(false)}
/>
)}
{/* Session Observer Modal */}
{isObserving && activeSession && (
<SessionObserverModal
isOpen={isObserving}
onClose={() => setIsObserving(false)}
session={{
sessionId: activeSession.id,
playerId: studentId,
startedAt:
typeof activeSession.createdAt === 'string'
? activeSession.createdAt
: activeSession.createdAt instanceof Date
? activeSession.createdAt.toISOString()
: new Date().toISOString(),
currentPartIndex: activeSession.currentPartIndex ?? 0,
currentSlotIndex: activeSession.currentSlotIndex ?? 0,
totalParts: activeSession.parts?.length ?? 1,
totalProblems: activeSession.parts?.reduce((sum, p) => sum + p.slots.length, 0) ?? 0,
completedProblems:
activeSession.results?.filter((r) => r.isCorrect !== null).length ?? 0,
}}
student={{
name: player.name,
emoji: player.emoji,
color: player.color,
}}
observerId={userId}
/>
)}
</PracticeErrorBoundary>
</PageWithNav>
</SessionModeBannerProvider>
)

View File

@@ -1,4 +1,6 @@
import { eq } from 'drizzle-orm'
import { notFound } from 'next/navigation'
import { db, schema } from '@/db'
import {
getAllSkillMastery,
getPlayer,
@@ -7,8 +9,25 @@ import {
getRecentSessionResults,
} from '@/lib/curriculum/server'
import { getActiveSessionPlan } from '@/lib/curriculum/session-planner'
import { getViewerId } from '@/lib/viewer'
import { DashboardClient } from './DashboardClient'
/**
* 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
}
// Disable caching for this page - progress data should be fresh
export const dynamic = 'force-dynamic'
@@ -34,6 +53,10 @@ export default async function DashboardPage({ params, searchParams }: DashboardP
const { studentId } = await params
const { tab } = await searchParams
// Get viewer ID for session observation authorization
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Fetch player data in parallel
const [player, curriculum, skills, recentSessions, activeSession, problemHistory] =
await Promise.all([
@@ -65,6 +88,7 @@ export default async function DashboardPage({ params, searchParams }: DashboardP
currentPracticingSkillIds={currentPracticingSkillIds}
problemHistory={problemHistory}
initialTab={tab as 'overview' | 'skills' | 'history' | undefined}
userId={user.id}
/>
)
}

View File

@@ -1,5 +1,6 @@
'use client'
import * as Dialog from '@radix-ui/react-dialog'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Z_INDEX } from '@/constants/zIndex'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
@@ -55,6 +56,7 @@ export function SessionObserverModal({
useSessionObserver(
isOpen ? session.sessionId : undefined,
isOpen ? observerId : undefined,
isOpen ? session.playerId : undefined,
isOpen
)
@@ -129,335 +131,336 @@ export function SessionObserverModal({
? String(Math.abs(state.currentProblem.answer)).length
: 3
if (!isOpen) return null
return (
<>
{/* Backdrop */}
<div
data-element="modal-backdrop"
className={css({
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
zIndex: Z_INDEX.TOOLTIP, // 15000 - above parent modals when nested
})}
onClick={onClose}
/>
{/* Modal - wider to fit abacus side by side */}
<div
data-component="session-observer-modal"
className={css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '90vw',
maxWidth: '800px',
maxHeight: '85vh',
backgroundColor: isDark ? 'gray.900' : 'white',
borderRadius: '16px',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
zIndex: Z_INDEX.TOOLTIP + 1, // Above the overlay
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
})}
>
{/* Header */}
<div
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<Dialog.Overlay
data-element="observer-modal-overlay"
className={css({
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
zIndex: Z_INDEX.NESTED_MODAL_BACKDROP,
})}
/>
<Dialog.Content
data-component="session-observer-modal"
className={css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '90vw',
maxWidth: '800px',
maxHeight: '85vh',
backgroundColor: isDark ? 'gray.900' : 'white',
borderRadius: '16px',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
zIndex: Z_INDEX.NESTED_MODAL,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
flexDirection: 'column',
outline: 'none',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '12px' })}>
<span
className={css({
width: '40px',
height: '40px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.25rem',
})}
style={{ backgroundColor: student.color }}
>
{student.emoji}
</span>
<div>
<h2
{/* Header */}
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '12px' })}>
<span
className={css({
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
fontSize: '1rem',
width: '40px',
height: '40px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.25rem',
})}
style={{ backgroundColor: student.color }}
>
{student.emoji}
</span>
<div>
<Dialog.Title
className={css({
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
fontSize: '1rem',
margin: 0,
})}
>
Observing {student.name}
</Dialog.Title>
<Dialog.Description
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.400' : 'gray.500',
margin: 0,
})}
>
Problem {session.completedProblems + 1} of {session.totalProblems}
</Dialog.Description>
</div>
</div>
<Dialog.Close asChild>
<button
type="button"
data-action="close-observer"
className={css({
padding: '8px 16px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.200' : 'gray.700',
border: 'none',
borderRadius: '8px',
fontSize: '0.875rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
})}
>
Observing {student.name}
</h2>
<p
Close
</button>
</Dialog.Close>
</div>
{/* Content */}
<div
className={css({
flex: 1,
padding: '24px',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '20px',
})}
>
{/* Connection status */}
{!isConnected && !error && (
<div
className={css({
fontSize: '0.8125rem',
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Problem {session.completedProblems + 1} of {session.totalProblems}
</p>
</div>
</div>
<p className={css({ fontSize: '1rem', marginBottom: '8px' })}>Connecting...</p>
</div>
)}
<button
type="button"
onClick={onClose}
data-action="close-observer"
className={css({
padding: '8px 16px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.200' : 'gray.700',
border: 'none',
borderRadius: '8px',
fontSize: '0.875rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
})}
>
Close
</button>
</div>
{/* Content */}
<div
className={css({
flex: 1,
padding: '24px',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '20px',
})}
>
{/* Connection status */}
{!isConnected && !error && (
<div
className={css({
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<p className={css({ fontSize: '1rem', marginBottom: '8px' })}>Connecting...</p>
</div>
)}
{error && (
<div
className={css({
textAlign: 'center',
color: isDark ? 'red.400' : 'red.600',
padding: '16px',
backgroundColor: isDark ? 'red.900/30' : 'red.50',
borderRadius: '8px',
})}
>
<p>{error}</p>
</div>
)}
{isObserving && !state && (
<div
className={css({
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<p className={css({ fontSize: '1rem', marginBottom: '8px' })}>
Waiting for student activity...
</p>
<p className={css({ fontSize: '0.875rem' })}>
You&apos;ll see their problem when they start working
</p>
</div>
)}
{/* Problem display with abacus dock - matches ActiveSession layout */}
{state && (
<div
data-element="observer-content"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '16px',
})}
>
{/* Purpose badge with tooltip - matches student's view */}
<PurposeBadge purpose={state.purpose} complexity={state.complexity} />
{/* Problem container with absolutely positioned AbacusDock */}
{error && (
<div
data-element="problem-with-dock"
className={css({
position: 'relative',
display: 'flex',
alignItems: 'flex-start',
textAlign: 'center',
color: isDark ? 'red.400' : 'red.600',
padding: '16px',
backgroundColor: isDark ? 'red.900/30' : 'red.50',
borderRadius: '8px',
})}
>
{/* Problem - ref for height measurement */}
<div ref={problemRef}>
<VerticalProblem
terms={state.currentProblem.terms}
userAnswer={state.studentAnswer}
isFocused={state.phase === 'problem'}
isCompleted={state.phase === 'feedback'}
correctAnswer={state.currentProblem.answer}
size="large"
/>
<p>{error}</p>
</div>
)}
{isObserving && !state && (
<div
className={css({
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<p className={css({ fontSize: '1rem', marginBottom: '8px' })}>
Waiting for student activity...
</p>
<p className={css({ fontSize: '0.875rem' })}>
You&apos;ll see their problem when they start working
</p>
</div>
)}
{/* Problem display with abacus dock - matches ActiveSession layout */}
{state && (
<div
data-element="observer-content"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '16px',
})}
>
{/* Purpose badge with tooltip - matches student's view */}
<PurposeBadge purpose={state.purpose} complexity={state.complexity} />
{/* Problem container with absolutely positioned AbacusDock */}
<div
data-element="problem-with-dock"
className={css({
position: 'relative',
display: 'flex',
alignItems: 'flex-start',
})}
>
{/* Problem - ref for height measurement */}
<div ref={problemRef}>
<VerticalProblem
terms={state.currentProblem.terms}
userAnswer={state.studentAnswer}
isFocused={state.phase === 'problem'}
isCompleted={state.phase === 'feedback'}
correctAnswer={state.currentProblem.answer}
size="large"
/>
</div>
{/* AbacusDock - positioned exactly like ActiveSession */}
{state.phase === 'problem' && (problemHeight ?? 0) > 0 && (
<AbacusDock
id="teacher-observer-dock"
columns={abacusColumns}
interactive={true}
showNumbers={false}
animated={true}
onValueChange={handleTeacherAbacusChange}
className={css({
position: 'absolute',
left: '100%',
top: 0,
width: '100%',
marginLeft: '1.5rem',
})}
style={{ height: problemHeight ?? undefined }}
/>
)}
</div>
{/* AbacusDock - positioned exactly like ActiveSession */}
{state.phase === 'problem' && (problemHeight ?? 0) > 0 && (
<AbacusDock
id="teacher-observer-dock"
columns={abacusColumns}
interactive={true}
showNumbers={false}
animated={true}
onValueChange={handleTeacherAbacusChange}
className={css({
position: 'absolute',
left: '100%',
top: 0,
width: '100%',
marginLeft: '1.5rem',
})}
style={{ height: problemHeight }}
{/* Feedback message */}
{state.studentAnswer && state.phase === 'feedback' && (
<PracticeFeedback
isCorrect={state.isCorrect ?? false}
correctAnswer={state.currentProblem.answer}
/>
)}
</div>
{/* Feedback message */}
{state.studentAnswer && state.phase === 'feedback' && (
<PracticeFeedback
isCorrect={state.isCorrect ?? false}
correctAnswer={state.currentProblem.answer}
/>
)}
</div>
)}
</div>
{/* Footer with connection status and controls */}
<div
className={css({
padding: '12px 20px',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
})}
>
{/* Connection status */}
<div className={css({ display: 'flex', alignItems: 'center', gap: '6px' })}>
<span
className={css({
width: '8px',
height: '8px',
borderRadius: '50%',
})}
style={{
backgroundColor: isObserving ? '#10b981' : isConnected ? '#eab308' : '#6b7280',
}}
/>
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{isObserving ? 'Live' : isConnected ? 'Connected' : 'Disconnected'}
</span>
)}
</div>
{/* Teacher controls: pause/resume and dock abaci */}
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
{/* Pause/Resume button */}
{isObserving && (
<button
type="button"
data-action={hasPausedSession ? 'resume-session' : 'pause-session'}
onClick={hasPausedSession ? handleResumeSession : handlePauseSession}
{/* Footer with connection status and controls */}
<div
className={css({
padding: '12px 20px',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
})}
>
{/* Connection status */}
<div className={css({ display: 'flex', alignItems: 'center', gap: '6px' })}>
<span
className={css({
padding: '8px 12px',
backgroundColor: hasPausedSession
? isDark
? 'green.700'
: 'green.100'
: isDark
? 'amber.700'
: 'amber.100',
color: hasPausedSession
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'amber.200'
: 'amber.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: {
width: '8px',
height: '8px',
borderRadius: '50%',
})}
style={{
backgroundColor: isObserving ? '#10b981' : isConnected ? '#eab308' : '#6b7280',
}}
/>
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{isObserving ? 'Live' : isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
{/* Teacher controls: pause/resume and dock abaci */}
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
{/* Pause/Resume button */}
{isObserving && (
<button
type="button"
data-action={hasPausedSession ? 'resume-session' : 'pause-session'}
onClick={hasPausedSession ? handleResumeSession : handlePauseSession}
className={css({
padding: '8px 12px',
backgroundColor: hasPausedSession
? isDark
? 'green.600'
: 'green.200'
? 'green.700'
: 'green.100'
: isDark
? 'amber.600'
: 'amber.200',
},
})}
>
{hasPausedSession ? '▶️ Resume' : '⏸️ Pause'}
</button>
)}
? 'amber.700'
: 'amber.100',
color: hasPausedSession
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'amber.200'
: 'amber.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: {
backgroundColor: hasPausedSession
? isDark
? 'green.600'
: 'green.200'
: isDark
? 'amber.600'
: 'amber.200',
},
})}
>
{hasPausedSession ? '▶️ Resume' : '⏸️ Pause'}
</button>
)}
{/* Dock both abaci button */}
{state && state.phase === 'problem' && (
<button
type="button"
data-action="dock-both-abaci"
onClick={handleDockBothAbaci}
disabled={!isObserving}
className={css({
padding: '8px 12px',
backgroundColor: isDark ? 'blue.700' : 'blue.100',
color: isDark ? 'blue.200' : 'blue.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'blue.600' : 'blue.200' },
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
})}
>
🧮 Dock Abaci
</button>
)}
{/* Dock both abaci button */}
{state && state.phase === 'problem' && (
<button
type="button"
data-action="dock-both-abaci"
onClick={handleDockBothAbaci}
disabled={!isObserving}
className={css({
padding: '8px 12px',
backgroundColor: isDark ? 'blue.700' : 'blue.100',
color: isDark ? 'blue.200' : 'blue.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'blue.600' : 'blue.200' },
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
})}
>
🧮 Dock Abaci
</button>
)}
</div>
</div>
</div>
</div>
</>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}

View File

@@ -4,19 +4,22 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { animated, useSpring } from '@react-spring/web'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { EnrollChildModal, SessionObserverModal } from '@/components/classroom'
import { EnrollChildModal } from '@/components/classroom'
import { FamilyCodeDisplay } from '@/components/family'
import { Z_INDEX } from '@/constants/zIndex'
import { usePageTransition } from '@/contexts/PageTransitionContext'
import { useTheme } from '@/contexts/ThemeContext'
import { useMyClassroom, type ActiveSessionInfo } from '@/hooks/useClassroom'
import { useMyClassroom } from '@/hooks/useClassroom'
import { usePlayerCurriculumQuery } from '@/hooks/usePlayerCurriculum'
import { useSessionMode } from '@/hooks/useSessionMode'
import { useStudentActions, type StudentActionData } from '@/hooks/useStudentActions'
import { useStudentStakeholders } from '@/hooks/useStudentStakeholders'
import { useUpdatePlayer } from '@/hooks/useUserPlayers'
import type { StudentActivity, StudentRelationship, UnifiedStudent } from '@/types/student'
import { css } from '../../../styled-system/css'
import { MiniStartPracticeBanner } from './MiniStartPracticeBanner'
import { RelationshipCard } from './RelationshipCard'
import { RelationshipSummary } from './RelationshipBadge'
import { ACTION_DEFINITIONS } from './studentActions'
// ============================================================================
@@ -49,9 +52,11 @@ interface NotesModalProps {
sourceBounds: DOMRect | null
/** Called when the modal should close */
onClose: () => void
/** Callback when "Watch Session" is clicked - handled by parent to avoid z-index issues */
onObserveSession?: (sessionId: string) => void
}
type TabId = 'overview' | 'notes'
type TabId = 'overview' | 'notes' | 'relationships'
// ============================================================================
// Helper functions
@@ -115,7 +120,7 @@ function buildStudentActionData(student: StudentProp): StudentActionData {
* - Overflow menu: All student actions (uses shared useStudentActions hook)
* - Zoom animation from source tile
*/
export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModalProps) {
export function NotesModal({ isOpen, student, sourceBounds, onClose, onObserveSession }: NotesModalProps) {
const router = useRouter()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@@ -128,15 +133,18 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
const [isSaving, setIsSaving] = useState(false)
const [isHiddenForTransition, setIsHiddenForTransition] = useState(false)
// State for session observer modal
const [showSessionObserver, setShowSessionObserver] = useState(false)
const modalRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// ========== Use shared student actions hook ==========
const studentActionData = buildStudentActionData(student)
const { actions, handlers, modals } = useStudentActions(studentActionData)
const { actions, handlers, modals } = useStudentActions(studentActionData, {
// Session observer is rendered at parent level to avoid z-index issues
onObserveSession:
onObserveSession && student.activity?.sessionId
? () => onObserveSession(student.activity!.sessionId!)
: undefined,
})
// ========== Additional data for Overview tab ==========
const { data: curriculumData } = usePlayerCurriculumQuery(student.id)
@@ -145,24 +153,19 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
const isTeacher = !!classroom
const updatePlayer = useUpdatePlayer() // For notes only
// Build ActiveSessionInfo for session observer (when student is practicing)
const activeSessionInfo = useMemo<ActiveSessionInfo | null>(() => {
const activityData = student.activity ?? null
if (activityData?.status !== 'practicing' || !activityData.sessionId) {
return null
}
const progress = activityData.sessionProgress
return {
sessionId: activityData.sessionId,
playerId: student.id,
startedAt: new Date().toISOString(), // Best guess - not stored in activity
currentPartIndex: 0,
currentSlotIndex: progress?.current ?? 0,
totalParts: 1,
totalProblems: progress?.total ?? 0,
completedProblems: progress?.current ?? 0,
}
}, [student.activity, student.id])
// ========== Stakeholder data for Relationships tab ==========
const { data: stakeholdersData } = useStudentStakeholders(student.id)
const viewerRelationship = stakeholdersData?.viewerRelationship ?? null
const stakeholders = stakeholdersData?.stakeholders ?? null
// Count other stakeholders for the summary line
const otherStakeholders = useMemo(() => {
if (!stakeholders) return undefined
const otherParents = stakeholders.parents.filter((p) => !p.isMe).length
const teacherCount = stakeholders.enrolledClassrooms.length
if (otherParents === 0 && teacherCount === 0) return undefined
return { parents: otherParents, teachers: teacherCount }
}, [stakeholders])
// ========== Derived data ==========
const relationship: StudentRelationship | null = student.relationship ?? null
@@ -203,13 +206,11 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
}, [onClose, router, student.id])
const handleBannerWatchSession = useCallback(() => {
// Open session observer modal
setShowSessionObserver(true)
}, [])
const handleSessionObserverClose = useCallback(() => {
setShowSessionObserver(false)
}, [])
// Session observer is rendered at parent level to avoid z-index issues
if (onObserveSession && student.activity?.sessionId) {
onObserveSession(student.activity.sessionId)
}
}, [onObserveSession, student.activity?.sessionId])
// ========== Effects ==========
@@ -220,7 +221,6 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
setIsEditing(false)
setActiveTab(defaultTab)
setIsHiddenForTransition(false)
setShowSessionObserver(false)
}
}, [isOpen, student.id, student.notes, defaultTab])
@@ -420,7 +420,7 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
{student.emoji}
</div>
{/* Name */}
{/* Name and relationship summary */}
<div className={css({ flex: 1, minWidth: 0 })}>
<h2
className={css({
@@ -435,6 +435,22 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
>
{student.name}
</h2>
{/* Relationship summary line */}
{viewerRelationship && viewerRelationship.type !== 'none' && (
<div
className={css({
marginTop: '2px',
opacity: 0.9,
})}
>
<RelationshipSummary
type={viewerRelationship.type}
classroomName={viewerRelationship.classroomName}
otherStakeholders={otherStakeholders}
className={css({ color: 'rgba(255, 255, 255, 0.9)' })}
/>
</div>
)}
</div>
{/* Fullscreen button - expands to dashboard */}
@@ -624,8 +640,8 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
onWatchSession={handleBannerWatchSession}
/>
{/* Tab bar - only show if Overview has content */}
{hasOverviewContent && (
{/* Tab bar - show if Overview has content or if we have stakeholder data */}
{(hasOverviewContent || stakeholdersData) && (
<div
data-section="tabs"
className={css({
@@ -635,18 +651,26 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
backgroundColor: isDark ? 'gray.800' : 'gray.50',
})}
>
<TabButton
label="Overview"
isActive={activeTab === 'overview'}
onClick={() => setActiveTab('overview')}
isDark={isDark}
/>
{hasOverviewContent && (
<TabButton
label="Overview"
isActive={activeTab === 'overview'}
onClick={() => setActiveTab('overview')}
isDark={isDark}
/>
)}
<TabButton
label="Notes"
isActive={activeTab === 'notes'}
onClick={() => setActiveTab('notes')}
isDark={isDark}
/>
<TabButton
label="Relationships"
isActive={activeTab === 'relationships'}
onClick={() => setActiveTab('relationships')}
isDark={isDark}
/>
</div>
)}
@@ -671,6 +695,8 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
activity={activity}
isDark={isDark}
/>
) : activeTab === 'relationships' ? (
<RelationshipsTab playerId={student.id} />
) : (
<NotesTab
notes={student.notes ?? null}
@@ -705,21 +731,6 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
playerId={student.id}
playerName={student.name}
/>
{/* Session Observer Modal - for teachers watching student practice */}
{showSessionObserver && activeSessionInfo && classroom && (
<SessionObserverModal
isOpen={showSessionObserver}
onClose={handleSessionObserverClose}
session={activeSessionInfo}
student={{
name: student.name,
emoji: student.emoji,
color: student.color,
}}
observerId={classroom.teacherId}
/>
)}
</>
)
}
@@ -1163,6 +1174,28 @@ function NotesTab({
)
}
// ============================================================================
// Relationships Tab Component
// ============================================================================
interface RelationshipsTabProps {
playerId: string
}
function RelationshipsTab({ playerId }: RelationshipsTabProps) {
return (
<div
className={css({
flex: 1,
padding: '16px',
overflow: 'auto',
})}
>
<RelationshipCard playerId={playerId} compact />
</div>
)
}
// ============================================================================
// Style Helper Functions
// ============================================================================

View File

@@ -1,14 +1,24 @@
'use client'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import * as HoverCard from '@radix-ui/react-hover-card'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { NavBannerSlot } from './BannerSlots'
import { Z_INDEX } from '@/constants/zIndex'
import { useTheme } from '@/contexts/ThemeContext'
import { useStudentStakeholders } from '@/hooks/useStudentStakeholders'
import { useActiveSessionPlan } from '@/hooks/useSessionPlan'
import { useStudentActions, type StudentActionData } from '@/hooks/useStudentActions'
import type { SessionPart, SlotResult } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import { EnrollChildModal } from '@/components/classroom'
import { FamilyCodeDisplay } from '@/components/family'
import { RelationshipCard } from './RelationshipCard'
import { RelationshipSummary } from './RelationshipBadge'
import { SessionMoodIndicator } from './SessionMoodIndicator'
import { SessionProgressIndicator } from './SessionProgressIndicator'
import { ACTION_DEFINITIONS } from './studentActions'
/**
* Timing data for the current problem attempt
@@ -73,11 +83,15 @@ interface PracticeSubNavProps {
name: string
emoji: string
color: string
/** Optional: needed for action menu (archive/unarchive) */
isArchived?: boolean
}
/** Current page context (shown as subtle label) */
pageContext?: 'dashboard' | 'configure' | 'session' | 'summary' | 'resume'
/** Session HUD data (shown when in active session) */
sessionHud?: SessionHudData
/** Optional callback when observe session is clicked */
onObserveSession?: (sessionId: string) => void
}
// Minimum samples needed for statistical display
@@ -118,11 +132,70 @@ function calculateStats(times: number[]): {
* - Session HUD controls when in an active session
* - Consistent visual identity across all practice pages
*/
export function PracticeSubNav({ student, pageContext, sessionHud }: PracticeSubNavProps) {
export function PracticeSubNav({
student,
pageContext,
sessionHud,
onObserveSession,
}: PracticeSubNavProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const isOnDashboard = pageContext === 'dashboard'
const isInActiveSession = !!sessionHud
// Stakeholder data for relationship popover
const { data: stakeholdersData } = useStudentStakeholders(student.id)
const viewerRelationship = stakeholdersData?.viewerRelationship ?? null
const stakeholders = stakeholdersData?.stakeholders ?? null
const hasOtherStakeholders =
(stakeholders?.parents.filter((p) => !p.isMe).length ?? 0) > 0 ||
(stakeholders?.enrolledClassrooms.length ?? 0) > 0
// Check for active session (for "Watch Session" action when not our own session)
const { data: activeSession } = useActiveSessionPlan(student.id)
const hasActiveSession = !!activeSession && !isInActiveSession
// Build StudentActionData for the action menu
const studentActionData: StudentActionData = useMemo(() => {
const relationship = viewerRelationship
? {
isMyChild: viewerRelationship.type === 'parent',
isEnrolled: viewerRelationship.type === 'teacher',
isPresent: viewerRelationship.type === 'observer',
enrollmentStatus: null,
}
: undefined
return {
id: student.id,
name: student.name,
isArchived: student.isArchived,
relationship,
activity: hasActiveSession
? {
status: 'practicing',
sessionId: activeSession?.id,
}
: undefined,
}
}, [student.id, student.name, student.isArchived, viewerRelationship, hasActiveSession, activeSession?.id])
// Use student actions hook for menu logic
const { actions, handlers, modals, classrooms } = useStudentActions(studentActionData, {
onObserveSession,
})
// Check if we have any actions to show
const hasAnyAction =
actions.startPractice ||
actions.watchSession ||
actions.enterClassroom ||
actions.leaveClassroom ||
actions.enrollInClassroom ||
actions.shareAccess ||
actions.archive ||
actions.unarchive
// Live-updating current problem timer
const [currentElapsedMs, setCurrentElapsedMs] = useState(0)
@@ -201,17 +274,15 @@ export function PracticeSubNav({ student, pageContext, sessionHud }: PracticeSub
maxWidth: '100vw',
})}
>
{/* Left: Student avatar + name (link to dashboard) */}
<Link
href={`/practice/${student.id}/dashboard`}
{/* Left: Student identity with clear visual zones */}
<div
data-element="student-nav-link"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
textDecoration: 'none',
gap: '0.5rem',
borderRadius: '8px',
padding: '0.375rem 0.75rem 0.375rem 0.375rem',
padding: '0.375rem',
marginLeft: '-0.375rem',
transition: 'all 0.15s ease',
backgroundColor: isOnDashboard ? (isDark ? 'gray.800' : 'white') : 'transparent',
@@ -219,36 +290,55 @@ export function PracticeSubNav({ student, pageContext, sessionHud }: PracticeSub
backgroundColor: isDark ? 'gray.800' : 'white',
},
})}
aria-current={isOnDashboard ? 'page' : undefined}
>
{/* Avatar */}
<div
data-element="student-avatar"
{/* Zone 1: Avatar - links to dashboard */}
<Link
href={`/practice/${student.id}/dashboard`}
className={css({
width: '36px',
height: '36px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.25rem',
textDecoration: 'none',
flexShrink: 0,
borderRadius: '50%',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
_hover: {
transform: 'scale(1.05)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
},
})}
style={{ backgroundColor: student.color }}
aria-label={`${student.name}'s dashboard`}
>
{student.emoji}
</div>
{/* Name + context - hidden on mobile during session */}
<div
data-element="student-avatar"
className={css({
width: '36px',
height: '36px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.25rem',
})}
style={{ backgroundColor: student.color }}
>
{student.emoji}
</div>
</Link>
{/* Zone 2: Name + relationship info - hidden on mobile during session */}
<div
className={css({
display: sessionHud ? { base: 'none', sm: 'flex' } : 'flex',
flexDirection: 'column',
gap: '0',
minWidth: 0,
flex: 1,
})}
>
<span
{/* Name - links to dashboard */}
<Link
href={`/practice/${student.id}/dashboard`}
className={css({
textDecoration: 'none',
fontSize: '0.9375rem',
fontWeight: '600',
color: isDark ? 'gray.100' : 'gray.800',
@@ -256,24 +346,343 @@ export function PracticeSubNav({ student, pageContext, sessionHud }: PracticeSub
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: 'fit-content',
_hover: {
textDecoration: 'underline',
color: isDark ? 'blue.300' : 'blue.600',
},
})}
aria-current={isOnDashboard ? 'page' : undefined}
>
{student.name}
</span>
</Link>
{/* Relationship summary with hover tooltip */}
{!sessionHud && (
<span
className={css({
fontSize: '0.6875rem',
color: isDark ? 'gray.500' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
{isOnDashboard ? 'Dashboard' : 'Back to dashboard'}
</span>
<>
{viewerRelationship && viewerRelationship.type !== 'none' ? (
<HoverCard.Root openDelay={200} closeDelay={100}>
<HoverCard.Trigger asChild>
<button
type="button"
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
cursor: 'help',
background: 'none',
border: 'none',
padding: '2px 0',
textAlign: 'left',
width: 'fit-content',
borderRadius: '4px',
transition: 'background-color 0.15s ease',
_hover: {
backgroundColor: isDark ? 'gray.700/50' : 'gray.100',
},
})}
aria-label="View relationship details"
>
<RelationshipSummary
type={viewerRelationship.type}
classroomName={viewerRelationship.classroomName}
otherStakeholders={
hasOtherStakeholders
? {
parents: stakeholders?.parents.filter((p) => !p.isMe).length ?? 0,
teachers: stakeholders?.enrolledClassrooms.length ?? 0,
}
: undefined
}
className={css({
fontSize: '0.6875rem !important',
opacity: 0.8,
})}
/>
{/* Info icon to indicate hover for more */}
<span
className={css({
fontSize: '0.625rem',
opacity: 0.5,
marginLeft: '2px',
})}
aria-hidden="true"
>
</span>
</button>
</HoverCard.Trigger>
{/* Relationship tooltip content */}
<HoverCard.Portal>
<HoverCard.Content
data-component="relationship-tooltip"
side="bottom"
align="start"
sideOffset={8}
className={css({
width: '320px',
maxWidth: 'calc(100vw - 32px)',
padding: '12px',
borderRadius: '12px',
backgroundColor: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
boxShadow: 'lg',
zIndex: Z_INDEX.POPOVER,
animation: 'fadeIn 0.15s ease',
})}
>
<RelationshipCard playerId={student.id} compact />
<HoverCard.Arrow
className={css({
fill: isDark ? 'gray.800' : 'white',
})}
/>
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
) : (
<span
className={css({
fontSize: '0.6875rem',
color: isDark ? 'gray.500' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
{isOnDashboard ? 'Dashboard' : 'Back to dashboard'}
</span>
)}
</>
)}
</div>
</Link>
{/* Zone 3: Actions menu button - separate, clearly clickable */}
{!isInActiveSession && hasAnyAction && (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
data-element="student-actions-trigger"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.300',
backgroundColor: isDark ? 'gray.800' : 'white',
color: isDark ? 'gray.400' : 'gray.500',
fontSize: '1rem',
cursor: 'pointer',
transition: 'all 0.15s ease',
flexShrink: 0,
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderColor: isDark ? 'gray.600' : 'gray.400',
color: isDark ? 'gray.200' : 'gray.700',
},
_focus: {
outline: '2px solid',
outlineColor: isDark ? 'blue.500' : 'blue.400',
outlineOffset: '2px',
},
})}
aria-label="Student actions"
>
</button>
</DropdownMenu.Trigger>
{/* Action menu dropdown */}
<DropdownMenu.Portal>
<DropdownMenu.Content
data-component="student-action-menu"
className={css({
minWidth: '200px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
padding: '4px',
boxShadow: 'lg',
zIndex: Z_INDEX.DROPDOWN,
animation: 'fadeIn 0.15s ease',
})}
sideOffset={8}
align="end"
>
{/* Go to Dashboard - when not on dashboard */}
{!isOnDashboard && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={() => {
window.location.href = `/practice/${student.id}/dashboard`
}}
>
<span>📊</span>
<span>Go to Dashboard</span>
</DropdownMenu.Item>
)}
{/* Practice actions */}
{actions.startPractice && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.startPractice}
>
<span>{ACTION_DEFINITIONS.startPractice.icon}</span>
<span>{ACTION_DEFINITIONS.startPractice.label}</span>
</DropdownMenu.Item>
)}
{actions.watchSession && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.watchSession}
>
<span>{ACTION_DEFINITIONS.watchSession.icon}</span>
<span>{ACTION_DEFINITIONS.watchSession.label}</span>
</DropdownMenu.Item>
)}
{/* Classroom section */}
{(classrooms.enrolled.length > 0 || classrooms.current) && (
<>
<DropdownMenu.Separator className={separatorStyles(isDark)} />
{/* If in a classroom, show presence + leave */}
{classrooms.current && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.leaveClassroom}
data-action="leave-classroom"
>
<span
className={css({
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'green.500',
})}
/>
<span>In {classrooms.current.classroom.name} Leave</span>
</DropdownMenu.Item>
)}
{/* If not in classroom and has exactly 1 enrollment: direct action */}
{!classrooms.current && classrooms.enrolled.length === 1 && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.enterClassroom}
data-action="enter-classroom"
>
<span>🏫</span>
<span>Enter {classrooms.enrolled[0].name}</span>
</DropdownMenu.Item>
)}
{/* If not in classroom and has multiple enrollments: use submenu */}
{!classrooms.current && classrooms.enrolled.length > 1 && (
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger className={subTriggerStyles(isDark)}>
<span>🏫</span>
<span>Enter Classroom</span>
<span className={css({ marginLeft: 'auto' })}></span>
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent
className={css({
minWidth: '160px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
padding: '4px',
boxShadow: 'lg',
zIndex: Z_INDEX.DROPDOWN + 1,
})}
sideOffset={4}
>
{classrooms.enrolled.map((c) => (
<DropdownMenu.Item
key={c.id}
className={menuItemStyles(isDark)}
onSelect={() => handlers.enterSpecificClassroom(c.id)}
data-action="enter-specific-classroom"
>
{c.name}
</DropdownMenu.Item>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
)}
{/* Enroll option */}
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.openEnrollModal}
data-action="enroll-in-classroom"
>
<span></span>
<span>Enroll in Classroom</span>
</DropdownMenu.Item>
</>
)}
{/* Show enroll option even if no enrollments yet */}
{classrooms.enrolled.length === 0 && !classrooms.current && actions.enrollInClassroom && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.openEnrollModal}
data-action="enroll-in-classroom"
>
<span>{ACTION_DEFINITIONS.enrollInClassroom.icon}</span>
<span>{ACTION_DEFINITIONS.enrollInClassroom.label}</span>
</DropdownMenu.Item>
)}
<DropdownMenu.Separator className={separatorStyles(isDark)} />
{/* Management actions */}
{actions.archive && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.toggleArchive}
>
<span>{ACTION_DEFINITIONS.archive.icon}</span>
<span>{ACTION_DEFINITIONS.archive.label}</span>
</DropdownMenu.Item>
)}
{actions.unarchive && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.toggleArchive}
>
<span>{ACTION_DEFINITIONS.unarchive.icon}</span>
<span>{ACTION_DEFINITIONS.unarchive.label}</span>
</DropdownMenu.Item>
)}
{actions.shareAccess && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.openShareAccess}
>
<span>{ACTION_DEFINITIONS.shareAccess.icon}</span>
<span>{ACTION_DEFINITIONS.shareAccess.label}</span>
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
</div>
{/* Session HUD - takes full remaining width when in active session */}
{sessionHud && (
@@ -504,8 +913,79 @@ export function PracticeSubNav({ student, pageContext, sessionHud }: PracticeSub
})}
/>
)}
{/* Sub-modals for actions */}
<FamilyCodeDisplay
playerId={student.id}
playerName={student.name}
isOpen={modals.shareAccess.isOpen}
onClose={modals.shareAccess.close}
/>
<EnrollChildModal
isOpen={modals.enroll.isOpen}
onClose={modals.enroll.close}
playerId={student.id}
playerName={student.name}
/>
</nav>
)
}
// =============================================================================
// Helper style functions (shared with StudentActionMenu)
// =============================================================================
function menuItemStyles(isDark: boolean) {
return css({
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderRadius: '4px',
fontSize: '13px',
cursor: 'pointer',
outline: 'none',
color: isDark ? 'gray.200' : 'gray.700',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
_focus: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
})
}
function separatorStyles(isDark: boolean) {
return css({
height: '1px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
margin: '4px 0',
})
}
function subTriggerStyles(isDark: boolean) {
return css({
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderRadius: '4px',
fontSize: '13px',
cursor: 'pointer',
outline: 'none',
color: isDark ? 'gray.200' : 'gray.700',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
_focus: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
// SubTrigger specific: highlight when open
'&[data-state="open"]': {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
})
}
export default PracticeSubNav

View File

@@ -417,33 +417,6 @@ export function ProgressDashboard({
margin: '0 auto',
})}
>
{/* Page header */}
<div
className={css({
textAlign: 'center',
marginBottom: '0.5rem',
})}
>
<h1
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.800',
marginBottom: '0.25rem',
})}
>
Daily Practice
</h1>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Build your soroban skills one step at a time
</p>
</div>
{/* Current level card - BKT-based when skillHealth available */}
<div
data-section="current-level"

View File

@@ -0,0 +1,330 @@
'use client'
import { useMemo } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import type { StudentRelationship, ViewerRelationType } from '@/types/student'
import { css, cx } from '../../../styled-system/css'
import { Tooltip } from '../ui/Tooltip'
// =============================================================================
// Types
// =============================================================================
interface RelationshipBadgeProps {
/**
* The relationship data - can be the simple StudentRelationship or
* a ViewerRelationType string for when you already know the type
*/
relationship: StudentRelationship | ViewerRelationType | null
/**
* Optional count of other stakeholders (e.g., "2 other parents")
* Shown in tooltip when provided
*/
otherStakeholderCount?: {
parents: number
teachers: number
}
/** Size variant */
size?: 'sm' | 'md'
/** Additional class name */
className?: string
/** Whether to show tooltip on hover (default: true) */
showTooltip?: boolean
}
// =============================================================================
// Configuration
// =============================================================================
interface RelationshipConfig {
icon: string
label: string
description: string
color: {
light: { bg: string; border: string; icon: string }
dark: { bg: string; border: string; icon: string }
}
}
const RELATIONSHIP_CONFIGS: Record<ViewerRelationType, RelationshipConfig> = {
parent: {
icon: '🏠', // house emoji
label: 'Your Child',
description: 'You have full access to this student',
color: {
light: { bg: 'blue.100', border: 'blue.300', icon: 'blue.600' },
dark: { bg: 'blue.900/60', border: 'blue.700', icon: 'blue.400' },
},
},
teacher: {
icon: '📚', // books emoji
label: 'Your Student',
description: 'Enrolled in your classroom',
color: {
light: { bg: 'purple.100', border: 'purple.300', icon: 'purple.600' },
dark: { bg: 'purple.900/60', border: 'purple.700', icon: 'purple.400' },
},
},
observer: {
icon: '📍', // location pin emoji
label: 'Visiting',
description: 'Present in your classroom',
color: {
light: { bg: 'emerald.100', border: 'emerald.300', icon: 'emerald.600' },
dark: { bg: 'emerald.900/60', border: 'emerald.700', icon: 'emerald.400' },
},
},
none: {
icon: '👤', // person silhouette emoji
label: 'Student',
description: 'No direct relationship',
color: {
light: { bg: 'gray.100', border: 'gray.300', icon: 'gray.500' },
dark: { bg: 'gray.800', border: 'gray.700', icon: 'gray.500' },
},
},
}
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Convert StudentRelationship to ViewerRelationType
* Priority: parent > teacher > observer > none
*/
function getRelationType(
relationship: StudentRelationship | ViewerRelationType | null
): ViewerRelationType {
if (!relationship) return 'none'
// If already a string type, return it
if (typeof relationship === 'string') {
return relationship
}
// Convert StudentRelationship to type
if (relationship.isMyChild) return 'parent'
if (relationship.isEnrolled) return 'teacher'
if (relationship.isPresent) return 'observer'
return 'none'
}
/**
* Build tooltip content with optional stakeholder counts
*/
function buildTooltipContent(
config: RelationshipConfig,
otherStakeholders?: { parents: number; teachers: number }
): string {
const lines = [config.label]
if (otherStakeholders) {
const parts: string[] = []
if (otherStakeholders.parents > 0) {
parts.push(
`${otherStakeholders.parents} other parent${otherStakeholders.parents > 1 ? 's' : ''}`
)
}
if (otherStakeholders.teachers > 0) {
parts.push(
`${otherStakeholders.teachers} teacher${otherStakeholders.teachers > 1 ? 's' : ''}`
)
}
if (parts.length > 0) {
lines.push(parts.join(' • '))
}
}
return lines.join('\n')
}
// =============================================================================
// Component
// =============================================================================
/**
* RelationshipBadge - Compact icon showing viewer's relationship to a student
*
* Used on student tiles to quickly identify relationship at a glance.
* Shows colored icon with optional tooltip for more details.
*
* Colors:
* - Blue (🏠): Parent - this is your child
* - Purple (📚): Teacher - enrolled in your classroom
* - Emerald (📍): Observer - visiting your classroom
* - Gray (👤): None - no direct relationship
*/
export function RelationshipBadge({
relationship,
otherStakeholderCount,
size = 'md',
className,
showTooltip = true,
}: RelationshipBadgeProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const relationType = useMemo(() => getRelationType(relationship), [relationship])
const config = RELATIONSHIP_CONFIGS[relationType]
const colors = isDark ? config.color.dark : config.color.light
// Don't render anything for "none" relationship (or show a subtle indicator)
if (relationType === 'none') {
return null
}
const badgeSize = size === 'sm' ? '22px' : '26px'
const fontSize = size === 'sm' ? '0.75rem' : '0.875rem'
const tooltipContent = useMemo(
() => buildTooltipContent(config, otherStakeholderCount),
[config, otherStakeholderCount]
)
const badge = (
<div
data-component="relationship-badge"
data-type={relationType}
className={cx(
css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '6px',
border: '1px solid',
flexShrink: 0,
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
cursor: showTooltip ? 'help' : 'default',
_hover: showTooltip
? {
transform: 'scale(1.1)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
}
: {},
}),
className
)}
style={{
width: badgeSize,
height: badgeSize,
fontSize,
backgroundColor: `var(--colors-${colors.bg.replace(/[./]/g, '-')})`,
borderColor: `var(--colors-${colors.border.replace(/[./]/g, '-')})`,
}}
>
<span
style={{
filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.1))',
}}
>
{config.icon}
</span>
</div>
)
if (showTooltip) {
return (
<Tooltip content={tooltipContent} side="right" delayDuration={200}>
{badge}
</Tooltip>
)
}
return badge
}
// =============================================================================
// Companion: Relationship Summary Line
// =============================================================================
interface RelationshipSummaryProps {
/** Relationship type */
type: ViewerRelationType
/** Optional: classroom name for teacher/observer */
classroomName?: string
/** Optional: counts of other stakeholders */
otherStakeholders?: {
parents: number
teachers: number
}
/** Additional class name */
className?: string
}
/**
* RelationshipSummary - One-line text summary of relationship
*
* Used in card headers and modal subtitles.
* Example outputs:
* - "Your Child"
* - "Your Child • 1 other parent"
* - "Your Student • Math 101"
* - "Visiting • 2 teachers"
*/
export function RelationshipSummary({
type,
classroomName,
otherStakeholders,
className,
}: RelationshipSummaryProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const config = RELATIONSHIP_CONFIGS[type]
const parts: string[] = [config.label]
// Add classroom name for teachers/observers
if (classroomName && (type === 'teacher' || type === 'observer')) {
parts.push(classroomName)
}
// Add other stakeholder counts
if (otherStakeholders) {
const stakeholderParts: string[] = []
if (otherStakeholders.parents > 0) {
stakeholderParts.push(
`${otherStakeholders.parents} other parent${otherStakeholders.parents > 1 ? 's' : ''}`
)
}
if (otherStakeholders.teachers > 0) {
stakeholderParts.push(
`${otherStakeholders.teachers} teacher${otherStakeholders.teachers > 1 ? 's' : ''}`
)
}
if (stakeholderParts.length > 0) {
parts.push(stakeholderParts.join(', '))
}
}
if (type === 'none') {
return null
}
return (
<span
data-component="relationship-summary"
className={cx(
css({
fontSize: '0.75rem',
fontWeight: 'medium',
}),
className
)}
style={{
color: isDark
? `var(--colors-${config.color.dark.icon.replace(/[./]/g, '-')})`
: `var(--colors-${config.color.light.icon.replace(/[./]/g, '-')})`,
}}
>
{parts.join(' • ')}
</span>
)
}
// =============================================================================
// Exports
// =============================================================================
export { RELATIONSHIP_CONFIGS, getRelationType }
export type { RelationshipBadgeProps, RelationshipSummaryProps, RelationshipConfig }

View File

@@ -0,0 +1,654 @@
'use client'
import { useMemo } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { useStudentStakeholders } from '@/hooks/useStudentStakeholders'
import type {
EnrolledClassroomInfo,
ParentInfo,
PendingEnrollmentInfo,
PresenceInfo,
ViewerRelationshipSummary,
} from '@/types/student'
import { css, cx } from '../../../styled-system/css'
// =============================================================================
// Types
// =============================================================================
interface RelationshipCardProps {
/** The player ID to show relationships for */
playerId: string
/** Optional class name */
className?: string
/** Whether to show in compact mode (less padding, smaller text) */
compact?: boolean
}
// =============================================================================
// Main Component
// =============================================================================
/**
* RelationshipCard - Shows complete relationship information for a student
*
* Displays:
* - Viewer's relationship to the student (prominent)
* - All linked parents
* - All enrolled classrooms (with teachers)
* - Any pending enrollment requests
* - Current classroom presence
*/
export function RelationshipCard({ playerId, className, compact = false }: RelationshipCardProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { data, isLoading } = useStudentStakeholders(playerId)
if (isLoading) {
return (
<div
data-component="relationship-card"
data-status="loading"
className={cx(
css({
padding: compact ? '12px' : '16px',
borderRadius: '12px',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
}),
className
)}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '8px',
color: isDark ? 'gray.400' : 'gray.500',
fontSize: compact ? '0.8125rem' : '0.875rem',
})}
>
<span className={css({ animation: 'pulse 1.5s ease-in-out infinite' })}>Loading...</span>
</div>
</div>
)
}
if (!data) {
return null
}
const { stakeholders, viewerRelationship } = data
return (
<div
data-component="relationship-card"
className={cx(
css({
display: 'flex',
flexDirection: 'column',
gap: compact ? '12px' : '16px',
}),
className
)}
>
{/* Viewer's Relationship - Primary/Prominent */}
<ViewerRelationshipSection
relationship={viewerRelationship}
presence={stakeholders.currentPresence}
isDark={isDark}
compact={compact}
/>
{/* Other Stakeholders */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: compact ? '8px' : '12px',
})}
>
{/* Parents Section */}
{stakeholders.parents.length > 0 && (
<StakeholderSection
title="Parents"
icon="👪"
isDark={isDark}
compact={compact}
>
<div className={css({ display: 'flex', flexWrap: 'wrap', gap: '6px' })}>
{stakeholders.parents.map((parent) => (
<ParentBadge key={parent.id} parent={parent} isDark={isDark} compact={compact} />
))}
</div>
</StakeholderSection>
)}
{/* Classrooms Section */}
{stakeholders.enrolledClassrooms.length > 0 && (
<StakeholderSection
title="Classrooms"
icon="🏫"
isDark={isDark}
compact={compact}
>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '6px' })}>
{stakeholders.enrolledClassrooms.map((classroom) => (
<ClassroomRow
key={classroom.id}
classroom={classroom}
isPresent={stakeholders.currentPresence?.classroomId === classroom.id}
isDark={isDark}
compact={compact}
/>
))}
</div>
</StakeholderSection>
)}
{/* Pending Enrollments */}
{stakeholders.pendingEnrollments.length > 0 && (
<StakeholderSection
title="Pending"
icon="⌛"
isDark={isDark}
compact={compact}
>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '6px' })}>
{stakeholders.pendingEnrollments.map((pending) => (
<PendingRow key={pending.id} pending={pending} isDark={isDark} compact={compact} />
))}
</div>
</StakeholderSection>
)}
</div>
</div>
)
}
// =============================================================================
// Viewer Relationship Section (Primary/Prominent)
// =============================================================================
interface ViewerRelationshipSectionProps {
relationship: ViewerRelationshipSummary
presence: PresenceInfo | null
isDark: boolean
compact: boolean
}
function ViewerRelationshipSection({
relationship,
presence,
isDark,
compact,
}: ViewerRelationshipSectionProps) {
const config = useMemo(() => {
switch (relationship.type) {
case 'parent':
return {
icon: '🏠', // house
title: 'Your Child',
subtitle: 'You have full access to this student',
color: {
light: { bg: 'blue.50', border: 'blue.200', text: 'blue.700', accent: 'blue.600' },
dark: {
bg: 'blue.900/40',
border: 'blue.700',
text: 'blue.300',
accent: 'blue.400',
},
},
}
case 'teacher':
return {
icon: '📚', // books
title: 'Your Student',
subtitle: relationship.classroomName
? `Enrolled in ${relationship.classroomName}`
: 'Enrolled in your classroom',
color: {
light: { bg: 'purple.50', border: 'purple.200', text: 'purple.700', accent: 'purple.600' },
dark: {
bg: 'purple.900/40',
border: 'purple.700',
text: 'purple.300',
accent: 'purple.400',
},
},
}
case 'observer':
return {
icon: '👀', // eyes
title: 'Visiting Student',
subtitle: presence ? `Present in ${presence.classroomName}` : 'In your classroom',
color: {
light: { bg: 'emerald.50', border: 'emerald.200', text: 'emerald.700', accent: 'emerald.600' },
dark: {
bg: 'emerald.900/40',
border: 'emerald.700',
text: 'emerald.300',
accent: 'emerald.400',
},
},
}
default:
return {
icon: '👤', // person silhouette
title: 'Student',
subtitle: 'No direct relationship',
color: {
light: { bg: 'gray.50', border: 'gray.200', text: 'gray.600', accent: 'gray.500' },
dark: { bg: 'gray.800', border: 'gray.700', text: 'gray.400', accent: 'gray.500' },
},
}
}
}, [relationship, presence])
const colors = isDark ? config.color.dark : config.color.light
return (
<div
data-element="viewer-relationship"
className={css({
padding: compact ? '12px' : '16px',
borderRadius: '12px',
border: '2px solid',
display: 'flex',
alignItems: 'center',
gap: compact ? '10px' : '14px',
})}
style={{
backgroundColor: `var(--colors-${colors.bg.replace(/[./]/g, '-')})`,
borderColor: `var(--colors-${colors.border.replace(/[./]/g, '-')})`,
}}
>
{/* Icon */}
<div
className={css({
width: compact ? '40px' : '48px',
height: compact ? '40px' : '48px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: compact ? '1.25rem' : '1.5rem',
flexShrink: 0,
})}
style={{
backgroundColor: `var(--colors-${colors.accent.replace(/[./]/g, '-')})`,
color: 'white',
}}
>
{config.icon}
</div>
{/* Text */}
<div className={css({ flex: 1, minWidth: 0 })}>
<div
className={css({
fontSize: compact ? '0.9375rem' : '1rem',
fontWeight: 'semibold',
})}
style={{ color: `var(--colors-${colors.text.replace(/[./]/g, '-')})` }}
>
{config.title}
</div>
<div
className={css({
fontSize: compact ? '0.75rem' : '0.8125rem',
marginTop: '2px',
})}
style={{ color: `var(--colors-${colors.text.replace(/[./]/g, '-')})`, opacity: 0.8 }}
>
{config.subtitle}
</div>
</div>
{/* Live indicator for presence */}
{presence && relationship.type !== 'none' && (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px 10px',
borderRadius: '9999px',
fontSize: '0.6875rem',
fontWeight: 'medium',
textTransform: 'uppercase',
letterSpacing: '0.05em',
backgroundColor: isDark ? 'emerald.800/60' : 'emerald.100',
color: isDark ? 'emerald.300' : 'emerald.700',
})}
>
<span
className={css({
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'emerald.500',
animation: 'pulse 2s ease-in-out infinite',
})}
/>
Live
</div>
)}
</div>
)
}
// =============================================================================
// Stakeholder Section
// =============================================================================
interface StakeholderSectionProps {
title: string
icon: string
isDark: boolean
compact: boolean
children: React.ReactNode
}
function StakeholderSection({ title, icon, isDark, compact, children }: StakeholderSectionProps) {
return (
<div
data-element={`stakeholder-section-${title.toLowerCase()}`}
className={css({
padding: compact ? '10px' : '12px',
borderRadius: '10px',
backgroundColor: isDark ? 'gray.800/50' : 'gray.50',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{/* Section header */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '6px',
marginBottom: compact ? '8px' : '10px',
})}
>
<span className={css({ fontSize: compact ? '0.875rem' : '1rem' })}>{icon}</span>
<span
className={css({
fontSize: compact ? '0.6875rem' : '0.75rem',
fontWeight: 'semibold',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{title}
</span>
</div>
{/* Content */}
{children}
</div>
)
}
// =============================================================================
// Parent Badge
// =============================================================================
interface ParentBadgeProps {
parent: ParentInfo
isDark: boolean
compact: boolean
}
function ParentBadge({ parent, isDark, compact }: ParentBadgeProps) {
// Get initials
const initials = parent.name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
return (
<div
data-element="parent-badge"
data-is-me={parent.isMe}
className={css({
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: compact ? '4px 8px' : '6px 10px',
borderRadius: '8px',
border: '1px solid',
})}
style={{
backgroundColor: parent.isMe
? isDark
? 'var(--colors-blue-900)'
: 'var(--colors-blue-50)'
: isDark
? 'var(--colors-gray-700)'
: 'var(--colors-gray-100)',
borderColor: parent.isMe
? isDark
? 'var(--colors-blue-700)'
: 'var(--colors-blue-200)'
: isDark
? 'var(--colors-gray-600)'
: 'var(--colors-gray-200)',
}}
>
{/* Avatar */}
<div
className={css({
width: compact ? '20px' : '24px',
height: compact ? '20px' : '24px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: compact ? '0.625rem' : '0.6875rem',
fontWeight: 'bold',
color: 'white',
flexShrink: 0,
})}
style={{
backgroundColor: parent.isMe
? 'var(--colors-blue-500)'
: isDark
? 'var(--colors-gray-500)'
: 'var(--colors-gray-400)',
}}
>
{initials}
</div>
{/* Name */}
<span
className={css({
fontSize: compact ? '0.75rem' : '0.8125rem',
fontWeight: parent.isMe ? 'medium' : 'normal',
})}
style={{
color: parent.isMe
? isDark
? 'var(--colors-blue-300)'
: 'var(--colors-blue-700)'
: isDark
? 'var(--colors-gray-300)'
: 'var(--colors-gray-700)',
}}
>
{parent.isMe ? 'You' : parent.name}
</span>
</div>
)
}
// =============================================================================
// Classroom Row
// =============================================================================
interface ClassroomRowProps {
classroom: EnrolledClassroomInfo
isPresent: boolean
isDark: boolean
compact: boolean
}
function ClassroomRow({ classroom, isPresent, isDark, compact }: ClassroomRowProps) {
return (
<div
data-element="classroom-row"
data-is-mine={classroom.isMyClassroom}
data-is-present={isPresent}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: compact ? '6px 8px' : '8px 10px',
borderRadius: '6px',
border: '1px solid',
})}
style={{
backgroundColor: classroom.isMyClassroom
? isDark
? 'var(--colors-purple-900)'
: 'var(--colors-purple-50)'
: 'transparent',
borderColor: classroom.isMyClassroom
? isDark
? 'var(--colors-purple-700)'
: 'var(--colors-purple-200)'
: isDark
? 'var(--colors-gray-700)'
: 'var(--colors-gray-200)',
}}
>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '2px' })}>
<div
className={css({
fontSize: compact ? '0.75rem' : '0.8125rem',
fontWeight: classroom.isMyClassroom ? 'medium' : 'normal',
})}
style={{
color: classroom.isMyClassroom
? isDark
? 'var(--colors-purple-300)'
: 'var(--colors-purple-700)'
: isDark
? 'var(--colors-gray-200)'
: 'var(--colors-gray-700)',
}}
>
{classroom.name}
{classroom.isMyClassroom && ' (Your classroom)'}
</div>
<div
className={css({
fontSize: compact ? '0.6875rem' : '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{classroom.teacherName}
</div>
</div>
{isPresent && (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '9999px',
fontSize: '0.625rem',
fontWeight: 'medium',
backgroundColor: isDark ? 'emerald.800/60' : 'emerald.100',
color: isDark ? 'emerald.300' : 'emerald.700',
})}
>
<span
className={css({
width: '5px',
height: '5px',
borderRadius: '50%',
backgroundColor: 'emerald.500',
animation: 'pulse 2s ease-in-out infinite',
})}
/>
Present
</div>
)}
</div>
)
}
// =============================================================================
// Pending Row
// =============================================================================
interface PendingRowProps {
pending: PendingEnrollmentInfo
isDark: boolean
compact: boolean
}
function PendingRow({ pending, isDark, compact }: PendingRowProps) {
return (
<div
data-element="pending-row"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: compact ? '6px 8px' : '8px 10px',
borderRadius: '6px',
backgroundColor: isDark ? 'amber.900/30' : 'amber.50',
border: '1px solid',
borderColor: isDark ? 'amber.800' : 'amber.200',
})}
>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '2px' })}>
<div
className={css({
fontSize: compact ? '0.75rem' : '0.8125rem',
color: isDark ? 'amber.200' : 'amber.800',
})}
>
{pending.classroomName}
</div>
<div
className={css({
fontSize: compact ? '0.6875rem' : '0.75rem',
color: isDark ? 'amber.400' : 'amber.600',
})}
>
{pending.teacherName}
</div>
</div>
<div
className={css({
padding: '2px 8px',
borderRadius: '9999px',
fontSize: '0.625rem',
fontWeight: 'medium',
backgroundColor: isDark ? 'amber.800/60' : 'amber.100',
color: isDark ? 'amber.300' : 'amber.700',
})}
>
{pending.pendingApproval === 'teacher' ? 'Needs Teacher' : 'Needs Parent'}
</div>
</div>
)
}
// =============================================================================
// Exports
// =============================================================================
export type { RelationshipCardProps }

View File

@@ -0,0 +1,308 @@
'use client'
import { useMemo } from 'react'
import type { StudentRelationship, EnrollmentStatus } from '@/types/student'
import { useTheme } from '@/contexts/ThemeContext'
import { css, cx } from '../../../styled-system/css'
import { Tooltip } from '../ui/Tooltip'
/**
* Individual relationship badge data
*/
interface RelationshipBadge {
key: string
icon: string
label: string
color: {
light: { bg: string; text: string; border: string }
dark: { bg: string; text: string; border: string }
}
/** Whether to show a pulse animation (for real-time indicators like "present") */
pulse?: boolean
}
/**
* Configuration for all relationship types
*/
const RELATIONSHIP_BADGES: Record<string, RelationshipBadge> = {
myChild: {
key: 'myChild',
icon: '🏠', // house emoji
label: 'My Child',
color: {
light: { bg: 'blue.50', text: 'blue.700', border: 'blue.200' },
dark: { bg: 'blue.900/40', text: 'blue.300', border: 'blue.700' },
},
},
enrolled: {
key: 'enrolled',
icon: '\u2713', // checkmark
label: 'Enrolled',
color: {
light: { bg: 'green.50', text: 'green.700', border: 'green.200' },
dark: { bg: 'green.900/40', text: 'green.300', border: 'green.700' },
},
},
present: {
key: 'present',
icon: '📍', // location pin emoji
label: 'In Class',
color: {
light: { bg: 'emerald.50', text: 'emerald.700', border: 'emerald.200' },
dark: { bg: 'emerald.900/40', text: 'emerald.300', border: 'emerald.700' },
},
pulse: true,
},
pendingTeacher: {
key: 'pendingTeacher',
icon: '⌛', // hourglass emoji
label: 'Pending (Teacher)',
color: {
light: { bg: 'amber.50', text: 'amber.700', border: 'amber.200' },
dark: { bg: 'amber.900/40', text: 'amber.300', border: 'amber.700' },
},
},
pendingParent: {
key: 'pendingParent',
icon: '⌛', // hourglass emoji
label: 'Pending (Parent)',
color: {
light: { bg: 'amber.50', text: 'amber.700', border: 'amber.200' },
dark: { bg: 'amber.900/40', text: 'amber.300', border: 'amber.700' },
},
},
}
/**
* Get the pending badge based on enrollment status
*/
function getPendingBadge(status: EnrollmentStatus): RelationshipBadge | null {
if (status === 'pending-teacher') return RELATIONSHIP_BADGES.pendingTeacher
if (status === 'pending-parent') return RELATIONSHIP_BADGES.pendingParent
return null
}
/**
* Convert StudentRelationship to array of badges to display
*/
function getActiveBadges(relationship: StudentRelationship): RelationshipBadge[] {
const badges: RelationshipBadge[] = []
// Order by importance: My Child > Enrolled > Present > Pending
if (relationship.isMyChild) {
badges.push(RELATIONSHIP_BADGES.myChild)
}
if (relationship.isEnrolled) {
badges.push(RELATIONSHIP_BADGES.enrolled)
}
if (relationship.isPresent) {
badges.push(RELATIONSHIP_BADGES.present)
}
const pendingBadge = getPendingBadge(relationship.enrollmentStatus)
if (pendingBadge) {
badges.push(pendingBadge)
}
return badges
}
export interface RelationshipIndicatorProps {
/** The relationship data to display */
relationship: StudentRelationship
/** Display variant: 'compact' for tiles, 'full' for dashboard/modal */
variant: 'compact' | 'full'
/** Show tooltip on hover (only applies to compact variant) */
showTooltip?: boolean
/** Additional class name */
className?: string
}
/**
* Displays relationship indicators (My Child, Enrolled, Present, Pending)
*
* Two variants:
* - `compact`: Small stacked icons with optional tooltips (for student tiles)
* - `full`: Horizontal row of labeled badges (for dashboard/modal)
*/
export function RelationshipIndicator({
relationship,
variant,
showTooltip = false,
className,
}: RelationshipIndicatorProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const badges = useMemo(() => getActiveBadges(relationship), [relationship])
// Don't render if no relationships
if (badges.length === 0) {
return null
}
if (variant === 'compact') {
return (
<div
data-element="relationship-indicator-compact"
className={cx(
css({
display: 'flex',
flexDirection: 'column',
gap: '4px',
}),
className
)}
>
{badges.map((badge) => (
<CompactBadge
key={badge.key}
badge={badge}
isDark={isDark}
showTooltip={showTooltip}
/>
))}
</div>
)
}
// Full variant: horizontal row with labels
return (
<div
data-element="relationship-indicator-full"
className={cx(
css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
}),
className
)}
>
{badges.map((badge) => (
<FullBadge key={badge.key} badge={badge} isDark={isDark} />
))}
</div>
)
}
/**
* Compact badge: icon only with optional tooltip
*/
function CompactBadge({
badge,
isDark,
showTooltip,
}: {
badge: RelationshipBadge
isDark: boolean
showTooltip: boolean
}) {
const colors = isDark ? badge.color.dark : badge.color.light
const badgeElement = (
<div
data-badge={badge.key}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '24px',
height: '24px',
borderRadius: '6px',
fontSize: '0.75rem',
border: '1px solid',
transition: 'transform 0.15s ease',
cursor: showTooltip ? 'help' : 'default',
_hover: showTooltip ? { transform: 'scale(1.1)' } : {},
})}
style={{
backgroundColor: `var(--colors-${colors.bg.replace(/[./]/g, '-')})`,
borderColor: `var(--colors-${colors.border.replace(/[./]/g, '-')})`,
color: `var(--colors-${colors.text.replace(/[./]/g, '-')})`,
}}
>
{badge.icon}
{badge.pulse && (
<span
className={css({
position: 'absolute',
top: '-2px',
right: '-2px',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'emerald.500',
animation: 'pulse 2s ease-in-out infinite',
})}
/>
)}
</div>
)
if (showTooltip) {
return (
<Tooltip content={badge.label} side="right" delayDuration={100}>
{badgeElement}
</Tooltip>
)
}
return badgeElement
}
/**
* Full badge: icon + label
*/
function FullBadge({
badge,
isDark,
}: {
badge: RelationshipBadge
isDark: boolean
}) {
const colors = isDark ? badge.color.dark : badge.color.light
return (
<div
data-badge={badge.key}
className={css({
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px 10px',
borderRadius: '9999px',
fontSize: '0.75rem',
fontWeight: 'medium',
border: '1px solid',
position: 'relative',
})}
style={{
backgroundColor: `var(--colors-${colors.bg.replace(/[./]/g, '-')})`,
borderColor: `var(--colors-${colors.border.replace(/[./]/g, '-')})`,
color: `var(--colors-${colors.text.replace(/[./]/g, '-')})`,
}}
>
<span className={css({ fontSize: '0.8125rem' })}>{badge.icon}</span>
<span>{badge.label}</span>
{badge.pulse && (
<span
className={css({
position: 'absolute',
top: '-2px',
right: '-2px',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'emerald.500',
animation: 'pulse 2s ease-in-out infinite',
})}
/>
)}
</div>
)
}
// Re-export types for convenience
export type { RelationshipBadge }
export { RELATIONSHIP_BADGES, getActiveBadges }

View File

@@ -71,6 +71,7 @@ function InteractiveSelectorDemo() {
<StudentSelector
students={sampleStudents}
onSelectStudent={(student) => console.log('Selected:', student.name)}
onToggleSelection={(student) => console.log('Toggled:', student.name)}
/>
</div>
)

View File

@@ -1,12 +1,17 @@
'use client'
import * as Checkbox from '@radix-ui/react-checkbox'
import * as HoverCard from '@radix-ui/react-hover-card'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { Z_INDEX } from '@/constants/zIndex'
import type { Player } from '@/types/player'
import type { EnrollmentStatus, StudentActivityStatus } from '@/types/student'
import { css } from '../../../styled-system/css'
import { NotesModal } from './NotesModal'
import { StudentActionMenu } from './StudentActionMenu'
import { RelationshipBadge } from './RelationshipBadge'
import { RelationshipCard } from './RelationshipCard'
import {
avatarStyles,
badgeStyles,
@@ -142,11 +147,11 @@ export interface StudentWithProgress extends Player {
isMyChild: boolean
isEnrolled: boolean
isPresent: boolean
enrollmentStatus: string | null
enrollmentStatus: EnrollmentStatus
}
/** Activity data for status display */
activity?: {
status: string
status: StudentActivityStatus
sessionProgress?: { current: number; total: number }
sessionId?: string
}
@@ -300,6 +305,61 @@ function StudentCard({
</div>
)}
{/* Relationship badge with hover tooltip - shows viewer's relationship to this student */}
{!isArchived && relationship && (
<HoverCard.Root openDelay={200} closeDelay={100}>
<HoverCard.Trigger asChild>
<button
type="button"
className={css({
position: 'absolute',
top: '6px',
left: '32px', // After checkbox
background: 'none',
border: 'none',
padding: 0,
cursor: 'help',
zIndex: 1,
})}
aria-label="View relationship details"
>
<RelationshipBadge
relationship={relationship}
size="sm"
showTooltip={false}
/>
</button>
</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content
data-component="relationship-tooltip"
side="right"
align="start"
sideOffset={8}
className={css({
width: '280px',
maxWidth: 'calc(100vw - 32px)',
padding: '12px',
borderRadius: '12px',
backgroundColor: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
boxShadow: 'lg',
zIndex: Z_INDEX.POPOVER,
animation: 'fadeIn 0.15s ease',
})}
>
<RelationshipCard playerId={student.id} compact />
<HoverCard.Arrow
className={css({
fill: isDark ? 'gray.800' : 'white',
})}
/>
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
)}
{/* Self-contained action menu - uses hooks internally for all actions */}
<StudentActionMenu
student={{
@@ -392,15 +452,13 @@ function StudentCard({
{student.name}
</span>
{/* Status badges row - only show if unified student data is available */}
{/* Activity badges - shows what the student is currently doing */}
{/* Relationship is shown via RelationshipBadge in top-left corner */}
{(activity?.status === 'practicing' ||
activity?.status === 'learning' ||
relationship?.isPresent ||
relationship?.isEnrolled ||
relationship?.isMyChild ||
relationship?.enrollmentStatus) && (
relationship?.isPresent) && (
<div
data-element="status-badges"
data-element="activity-badges"
className={css({
display: 'flex',
flexWrap: 'wrap',
@@ -453,7 +511,7 @@ function StudentCard({
</span>
)}
{/* Present badge - only show if not practicing/learning */}
{/* Present badge - show when in classroom but not actively practicing/learning */}
{relationship?.isPresent &&
activity?.status !== 'practicing' &&
activity?.status !== 'learning' && (
@@ -475,69 +533,6 @@ function StudentCard({
<span>Present</span>
</span>
)}
{/* Enrolled badge */}
{relationship?.isEnrolled && !relationship?.isPresent && (
<span
data-status="enrolled"
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
padding: '2px 6px',
borderRadius: '10px',
fontSize: '10px',
fontWeight: 'medium',
bg: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.600',
})}
>
<span>📋</span>
<span>Enrolled</span>
</span>
)}
{/* Your Child badge - show in enrolled view when it's your child */}
{relationship?.isMyChild && relationship?.isEnrolled && (
<span
data-status="your-child"
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
padding: '2px 6px',
borderRadius: '10px',
fontSize: '10px',
fontWeight: 'medium',
bg: isDark ? 'pink.900' : 'pink.100',
color: isDark ? 'pink.300' : 'pink.700',
})}
>
<span>👶</span>
<span>Your Child</span>
</span>
)}
{/* Pending badge */}
{relationship?.enrollmentStatus?.startsWith('pending') && (
<span
data-status="pending"
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
padding: '2px 6px',
borderRadius: '10px',
fontSize: '10px',
fontWeight: 'medium',
bg: isDark ? 'yellow.900' : 'yellow.100',
color: isDark ? 'yellow.300' : 'yellow.700',
})}
>
<span></span>
<span>Pending</span>
</span>
)}
</div>
)}
@@ -926,6 +921,7 @@ export function StudentSelector({
student={selectedStudent}
sourceBounds={sourceBounds}
onClose={handleCloseModal}
onObserveSession={onObserveSession}
/>
)}
</>

View File

@@ -6,7 +6,14 @@ import { css } from '../../../styled-system/css'
/**
* Available student list views
*/
export type StudentView = 'all' | 'my-children' | 'enrolled' | 'in-classroom'
export type StudentView =
| 'all'
| 'my-children'
| 'my-children-active'
| 'enrolled'
| 'in-classroom'
| 'in-classroom-active'
| 'needs-attention'
interface ViewConfig {
id: StudentView
@@ -14,6 +21,10 @@ interface ViewConfig {
icon: string
/** Only show this view to users who have a classroom */
teacherOnly?: boolean
/** Parent view ID - this is a sub-view that appears when parent has active sessions */
parentView?: StudentView
/** Short label for nested display */
shortLabel?: string
}
export const VIEW_CONFIGS: ViewConfig[] = [
@@ -22,11 +33,24 @@ export const VIEW_CONFIGS: ViewConfig[] = [
label: 'All',
icon: '👥',
},
{
id: 'needs-attention',
label: 'Needs Attention',
shortLabel: 'Attention',
icon: '🚨',
},
{
id: 'my-children',
label: 'My Children',
icon: '👶',
},
{
id: 'my-children-active',
label: 'Active Sessions',
shortLabel: 'Active',
icon: '🎯',
parentView: 'my-children',
},
{
id: 'enrolled',
label: 'Enrolled',
@@ -36,11 +60,28 @@ export const VIEW_CONFIGS: ViewConfig[] = [
{
id: 'in-classroom',
label: 'In Classroom',
shortLabel: 'Present',
icon: '🏫',
teacherOnly: true,
},
{
id: 'in-classroom-active',
label: 'Active Sessions',
shortLabel: 'Active',
icon: '🎯',
parentView: 'in-classroom',
teacherOnly: true,
},
]
// Map parent views to their subviews (for parent's "My Children" compound chip)
const PARENT_TO_SUBVIEW: Partial<Record<StudentView, StudentView>> = {
'my-children': 'my-children-active',
}
// Teacher compound chip: enrolled → in-classroom → in-classroom-active
const TEACHER_COMPOUND_VIEWS: StudentView[] = ['enrolled', 'in-classroom', 'in-classroom-active']
interface ViewSelectorProps {
/** Currently selected view */
currentView: StudentView
@@ -55,8 +96,8 @@ interface ViewSelectorProps {
/**
* View selector chips for filtering student list.
*
* Shows view options as clickable chips. Teachers see all 4 views,
* parents only see "All" and "My Children".
* Shows view options as clickable chips. Views with active session filters
* are rendered as compound chips with connected parent/filter sections.
*/
export function ViewSelector({
currentView,
@@ -67,6 +108,25 @@ export function ViewSelector({
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Check if teacher compound views are available
const hasTeacherCompound = TEACHER_COMPOUND_VIEWS.some((v) => availableViews.includes(v))
// Build a set of subview IDs that are available (have active count > 0)
const availableSubviews = new Set(
availableViews.filter((v) => VIEW_CONFIGS.find((c) => c.id === v)?.parentView)
)
// Filter views for rendering:
// - Exclude teacher compound views (rendered separately)
// - Exclude subviews (rendered as part of compound chips)
const regularViews = availableViews.filter((viewId) => {
// Exclude teacher compound views
if (TEACHER_COMPOUND_VIEWS.includes(viewId)) return false
// Exclude subviews (they're part of compound chips)
if (VIEW_CONFIGS.find((c) => c.id === viewId)?.parentView) return false
return true
})
return (
<div
data-component="view-selector"
@@ -82,102 +142,612 @@ export function ViewSelector({
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
// Prevent text selection while swiping
WebkitUserSelect: 'none',
userSelect: 'none',
// Add padding for mobile so chips don't touch edge when scrolling
paddingRight: { base: '8px', md: 0 },
})}
>
{availableViews.map((viewId) => {
{regularViews.map((viewId) => {
const config = VIEW_CONFIGS.find((v) => v.id === viewId)
if (!config) return null
const isActive = currentView === viewId
const count = viewCounts[viewId]
const subviewId = PARENT_TO_SUBVIEW[viewId]
const subviewConfig = subviewId ? VIEW_CONFIGS.find((c) => c.id === subviewId) : null
const hasActiveSubview = subviewId && availableSubviews.has(subviewId)
const isParentActive = currentView === viewId
const isSubviewActive = subviewId && currentView === subviewId
const parentCount = viewCounts[viewId]
const activeCount = subviewId ? viewCounts[subviewId] : undefined
// If this view has a subview with active sessions, render as compound chip
if (hasActiveSubview && subviewConfig) {
return (
<CompoundChip
key={viewId}
parentConfig={config}
subviewConfig={subviewConfig}
isParentActive={isParentActive}
isSubviewActive={!!isSubviewActive}
parentCount={parentCount}
activeCount={activeCount}
onParentClick={() => onViewChange(viewId)}
onSubviewClick={() => onViewChange(subviewId)}
isDark={isDark}
/>
)
}
// Otherwise render as simple chip
return (
<button
<SimpleChip
key={viewId}
type="button"
data-view={viewId}
data-active={isActive}
config={config}
isActive={isParentActive}
count={parentCount}
onClick={() => onViewChange(viewId)}
className={css({
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
borderRadius: '16px',
border: '1px solid',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 'medium',
transition: 'all 0.15s ease',
// Don't shrink on mobile scroll
flexShrink: 0,
whiteSpace: 'nowrap',
// Active state
bg: isActive ? (isDark ? 'blue.900' : 'blue.100') : isDark ? 'gray.800' : 'white',
borderColor: isActive
? isDark
? 'blue.600'
: 'blue.300'
: isDark
? 'gray.600'
: 'gray.300',
color: isActive
? isDark
? 'blue.300'
: 'blue.700'
: isDark
? 'gray.300'
: 'gray.700',
_hover: {
borderColor: isActive
? isDark
? 'blue.500'
: 'blue.400'
: isDark
? 'gray.500'
: 'gray.400',
bg: isActive ? (isDark ? 'blue.800' : 'blue.200') : isDark ? 'gray.700' : 'gray.50',
},
})}
>
<span>{config.icon}</span>
<span>{config.label}</span>
{count !== undefined && (
<span
data-element="view-count"
className={css({
fontSize: '11px',
fontWeight: 'bold',
padding: '1px 6px',
borderRadius: '10px',
bg: isActive
? isDark
? 'blue.700'
: 'blue.200'
: isDark
? 'gray.700'
: 'gray.200',
})}
>
{count}
</span>
)}
</button>
isDark={isDark}
/>
)
})}
{/* Teacher compound chip: Enrolled → In Classroom → Active */}
{hasTeacherCompound && (
<TeacherCompoundChip
currentView={currentView}
onViewChange={onViewChange}
viewCounts={viewCounts}
availableViews={availableViews}
isDark={isDark}
/>
)}
</div>
)
}
interface SimpleChipProps {
config: ViewConfig
isActive: boolean
count?: number
onClick: () => void
isDark: boolean
}
function SimpleChip({ config, isActive, count, onClick, isDark }: SimpleChipProps) {
// Needs-attention uses orange/red to indicate urgency
const isAttention = config.id === 'needs-attention'
const colorScheme = isAttention ? 'orange' : 'blue'
return (
<button
type="button"
data-view={config.id}
data-active={isActive}
onClick={onClick}
className={css({
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
borderRadius: '16px',
border: '1px solid',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 'medium',
transition: 'all 0.15s ease',
flexShrink: 0,
whiteSpace: 'nowrap',
bg: isActive
? isDark
? `${colorScheme}.900`
: `${colorScheme}.100`
: isDark
? 'gray.800'
: 'white',
borderColor: isActive
? isDark
? `${colorScheme}.600`
: `${colorScheme}.300`
: isDark
? 'gray.600'
: 'gray.300',
color: isActive
? isDark
? `${colorScheme}.300`
: `${colorScheme}.700`
: isDark
? 'gray.300'
: 'gray.700',
_hover: {
borderColor: isActive
? isDark
? `${colorScheme}.500`
: `${colorScheme}.400`
: isDark
? 'gray.500'
: 'gray.400',
bg: isActive
? isDark
? `${colorScheme}.800`
: `${colorScheme}.200`
: isDark
? 'gray.700'
: 'gray.50',
},
})}
>
<span>{config.icon}</span>
<span>{config.shortLabel ?? config.label}</span>
{count !== undefined && (
<span
data-element="view-count"
className={css({
fontSize: '11px',
fontWeight: 'bold',
padding: '1px 6px',
borderRadius: '10px',
bg: isActive
? isDark
? `${colorScheme}.700`
: `${colorScheme}.200`
: isDark
? 'gray.700'
: 'gray.200',
})}
>
{count}
</span>
)}
</button>
)
}
interface CompoundChipProps {
parentConfig: ViewConfig
subviewConfig: ViewConfig
isParentActive: boolean
isSubviewActive: boolean
parentCount?: number
activeCount?: number
onParentClick: () => void
onSubviewClick: () => void
isDark: boolean
}
/**
* Get available views based on whether user is a teacher
* Compound chip that groups a parent view with its active session filter.
*
* ┌─────────────────────────────────────────┐
* │ 👶 My Children (3) │ 🎯 Active (1) │
* └─────────────────────────────────────────┘
* ↑ parent part ↑ filter part
*/
export function getAvailableViews(isTeacher: boolean): StudentView[] {
return VIEW_CONFIGS.filter((v) => !v.teacherOnly || isTeacher).map((v) => v.id)
function CompoundChip({
parentConfig,
subviewConfig,
isParentActive,
isSubviewActive,
parentCount,
activeCount,
onParentClick,
onSubviewClick,
isDark,
}: CompoundChipProps) {
// Either part being selected makes the whole compound "active"
const isEitherActive = isParentActive || isSubviewActive
return (
<div
data-component="compound-chip"
data-parent-view={parentConfig.id}
data-active={isEitherActive}
className={css({
display: 'flex',
alignItems: 'stretch',
borderRadius: '16px',
border: '1px solid',
overflow: 'hidden',
flexShrink: 0,
transition: 'all 0.15s ease',
borderColor: isEitherActive
? isDark
? 'blue.600'
: 'blue.300'
: isDark
? 'gray.600'
: 'gray.300',
})}
>
{/* Parent part - left side */}
<button
type="button"
data-view={parentConfig.id}
data-active={isParentActive}
data-role="parent"
onClick={onParentClick}
className={css({
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 'medium',
transition: 'all 0.15s ease',
whiteSpace: 'nowrap',
border: 'none',
borderRight: '1px solid',
// Parent selected = blue background
bg: isParentActive
? isDark
? 'blue.900'
: 'blue.100'
: isSubviewActive
? isDark
? 'gray.800'
: 'gray.50'
: isDark
? 'gray.800'
: 'white',
borderColor: isDark ? 'gray.600' : 'gray.300',
color: isParentActive
? isDark
? 'blue.300'
: 'blue.700'
: isDark
? 'gray.300'
: 'gray.700',
_hover: {
bg: isParentActive
? isDark
? 'blue.800'
: 'blue.200'
: isDark
? 'gray.700'
: 'gray.100',
},
})}
>
<span>{parentConfig.icon}</span>
<span>{parentConfig.label}</span>
{parentCount !== undefined && (
<span
data-element="view-count"
className={css({
fontSize: '11px',
fontWeight: 'bold',
padding: '1px 6px',
borderRadius: '10px',
bg: isParentActive
? isDark
? 'blue.700'
: 'blue.200'
: isDark
? 'gray.700'
: 'gray.200',
})}
>
{parentCount}
</span>
)}
</button>
{/* Subview part - right side (active filter) */}
<button
type="button"
data-view={subviewConfig.id}
data-active={isSubviewActive}
data-role="filter"
onClick={onSubviewClick}
className={css({
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '6px 10px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: 'medium',
transition: 'all 0.15s ease',
whiteSpace: 'nowrap',
border: 'none',
// Subview selected = green background (indicates "live" status)
bg: isSubviewActive
? isDark
? 'green.900'
: 'green.100'
: isParentActive
? isDark
? 'gray.800'
: 'gray.50'
: isDark
? 'gray.800'
: 'white',
color: isSubviewActive
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'gray.400'
: 'gray.500',
_hover: {
bg: isSubviewActive
? isDark
? 'green.800'
: 'green.200'
: isDark
? 'gray.700'
: 'gray.100',
color: isSubviewActive
? isDark
? 'green.200'
: 'green.800'
: isDark
? 'gray.300'
: 'gray.600',
},
})}
>
<span>{subviewConfig.icon}</span>
<span>{subviewConfig.shortLabel ?? subviewConfig.label}</span>
{activeCount !== undefined && (
<span
data-element="active-count"
className={css({
fontSize: '10px',
fontWeight: 'bold',
padding: '1px 5px',
borderRadius: '8px',
bg: isSubviewActive
? isDark
? 'green.700'
: 'green.200'
: isDark
? 'gray.700'
: 'gray.200',
})}
>
{activeCount}
</span>
)}
</button>
</div>
)
}
interface TeacherCompoundChipProps {
currentView: StudentView
onViewChange: (view: StudentView) => void
viewCounts: Partial<Record<StudentView, number>>
availableViews: StudentView[]
isDark: boolean
}
/**
* Teacher compound chip: Enrolled → In Classroom → Active
*
* ┌──────────────────────────────────────────────────────────┐
* │ 📋 Enrolled (10) │ 🏫 Present (5) │ 🎯 Active (2) │
* └──────────────────────────────────────────────────────────┘
* ↑ all enrolled ↑ in classroom ↑ practicing
*/
function TeacherCompoundChip({
currentView,
onViewChange,
viewCounts,
availableViews,
isDark,
}: TeacherCompoundChipProps) {
const enrolledConfig = VIEW_CONFIGS.find((c) => c.id === 'enrolled')!
const inClassroomConfig = VIEW_CONFIGS.find((c) => c.id === 'in-classroom')!
const activeConfig = VIEW_CONFIGS.find((c) => c.id === 'in-classroom-active')!
const isEnrolledActive = currentView === 'enrolled'
const isInClassroomActive = currentView === 'in-classroom'
const isActiveActive = currentView === 'in-classroom-active'
const isAnyActive = isEnrolledActive || isInClassroomActive || isActiveActive
const hasActiveSegment =
availableViews.includes('in-classroom-active') && (viewCounts['in-classroom-active'] ?? 0) > 0
return (
<div
data-component="teacher-compound-chip"
data-active={isAnyActive}
className={css({
display: 'flex',
alignItems: 'stretch',
borderRadius: '16px',
border: '1px solid',
overflow: 'hidden',
flexShrink: 0,
transition: 'all 0.15s ease',
borderColor: isAnyActive
? isDark
? 'blue.600'
: 'blue.300'
: isDark
? 'gray.600'
: 'gray.300',
})}
>
{/* Enrolled segment */}
<ChipSegment
config={enrolledConfig}
isActive={isEnrolledActive}
count={viewCounts['enrolled']}
onClick={() => onViewChange('enrolled')}
isDark={isDark}
position="first"
isCompoundActive={isAnyActive}
colorScheme="blue"
/>
{/* In Classroom segment */}
<ChipSegment
config={inClassroomConfig}
isActive={isInClassroomActive}
count={viewCounts['in-classroom']}
onClick={() => onViewChange('in-classroom')}
isDark={isDark}
position={hasActiveSegment ? 'middle' : 'last'}
isCompoundActive={isAnyActive}
colorScheme="blue"
/>
{/* Active segment - only show if there are active sessions */}
{hasActiveSegment && (
<ChipSegment
config={activeConfig}
isActive={isActiveActive}
count={viewCounts['in-classroom-active']}
onClick={() => onViewChange('in-classroom-active')}
isDark={isDark}
position="last"
isCompoundActive={isAnyActive}
colorScheme="green"
/>
)}
</div>
)
}
interface ChipSegmentProps {
config: ViewConfig
isActive: boolean
count?: number
onClick: () => void
isDark: boolean
position: 'first' | 'middle' | 'last'
isCompoundActive: boolean
colorScheme: 'blue' | 'green'
}
function ChipSegment({
config,
isActive,
count,
onClick,
isDark,
position,
isCompoundActive,
colorScheme,
}: ChipSegmentProps) {
const isLast = position === 'last'
return (
<button
type="button"
data-view={config.id}
data-active={isActive}
data-position={position}
onClick={onClick}
className={css({
display: 'flex',
alignItems: 'center',
gap: '5px',
padding: '6px 10px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: 'medium',
transition: 'all 0.15s ease',
whiteSpace: 'nowrap',
border: 'none',
borderRight: isLast ? 'none' : '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
bg: isActive
? isDark
? `${colorScheme}.900`
: `${colorScheme}.100`
: isCompoundActive
? isDark
? 'gray.800'
: 'gray.50'
: isDark
? 'gray.800'
: 'white',
color: isActive
? isDark
? `${colorScheme}.300`
: `${colorScheme}.700`
: isDark
? 'gray.400'
: 'gray.600',
_hover: {
bg: isActive
? isDark
? `${colorScheme}.800`
: `${colorScheme}.200`
: isDark
? 'gray.700'
: 'gray.100',
color: isActive
? isDark
? `${colorScheme}.200`
: `${colorScheme}.800`
: isDark
? 'gray.300'
: 'gray.700',
},
})}
>
<span>{config.icon}</span>
<span>{config.shortLabel ?? config.label}</span>
{count !== undefined && (
<span
data-element="segment-count"
className={css({
fontSize: '10px',
fontWeight: 'bold',
padding: '1px 5px',
borderRadius: '8px',
bg: isActive
? isDark
? `${colorScheme}.700`
: `${colorScheme}.200`
: isDark
? 'gray.700'
: 'gray.200',
})}
>
{count}
</span>
)}
</button>
)
}
/**
* Get available views based on user type and active session counts
*
* Sub-views (like 'my-children-active') only appear when there are active sessions
* in the parent view.
*
* @param isTeacher - Whether the user is a teacher
* @param viewCounts - Map of view IDs to counts (used for conditional views)
*/
export function getAvailableViews(
isTeacher: boolean,
viewCounts?: Partial<Record<StudentView, number>>
): StudentView[] {
return VIEW_CONFIGS.filter((v) => {
// Filter by teacher-only
if (v.teacherOnly && !isTeacher) return false
// Sub-views only appear when parent has active sessions
if (v.parentView) {
const activeCount = viewCounts?.[v.id] ?? 0
return activeCount > 0
}
// Needs-attention only appears when there are students needing attention
if (v.id === 'needs-attention') {
const count = viewCounts?.['needs-attention'] ?? 0
return count > 0
}
return true
}).map((v) => v.id)
}
/**

View File

@@ -23,6 +23,10 @@ export { NotesModal as StudentQuickLook } from './NotesModal'
export { NumericKeypad } from './NumericKeypad'
export { PracticeErrorBoundary } from './PracticeErrorBoundary'
export { PracticeFeedback } from './PracticeFeedback'
export type { RelationshipBadgeProps, RelationshipConfig } from './RelationshipBadge'
export { RelationshipBadge, RelationshipSummary, RELATIONSHIP_CONFIGS, getRelationType } from './RelationshipBadge'
export type { RelationshipCardProps } from './RelationshipCard'
export { RelationshipCard } from './RelationshipCard'
export { PurposeBadge } from './PurposeBadge'
export type { SessionHudData } from './PracticeSubNav'
export { PracticeSubNav } from './PracticeSubNav'

View File

@@ -77,20 +77,22 @@ export function Tooltip({
onOpenChange,
}: TooltipProps) {
return (
<TooltipPrimitive.Root delayDuration={delayDuration} open={open} onOpenChange={onOpenChange}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side={side}
align={align}
sideOffset={6}
className={cx(contentStyles, contentClassName)}
>
{content}
<TooltipPrimitive.Arrow className={arrowStyles} width={12} height={6} />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
<TooltipPrimitive.Provider>
<TooltipPrimitive.Root delayDuration={delayDuration} open={open} onOpenChange={onOpenChange}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side={side}
align={align}
sideOffset={6}
className={cx(contentStyles, contentClassName)}
>
{content}
<TooltipPrimitive.Arrow className={arrowStyles} width={12} height={6} />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
)
}

View File

@@ -31,6 +31,10 @@ export const Z_INDEX = {
// Tooltips must be above modals so they work inside modals
TOOLTIP: 15000,
// Nested modals (above tooltips, for modals that open from within other modals)
NESTED_MODAL_BACKDROP: 16000,
NESTED_MODAL: 16001,
// Top-level overlays (20000+)
TOAST: 20000,

View File

@@ -0,0 +1,221 @@
'use client'
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
import { io, type Socket } from 'socket.io-client'
import { api } from '@/lib/queryClient'
import type { SessionStartedEvent, SessionEndedEvent } from '@/lib/classroom/socket-events'
/**
* Active session information for a child
*/
export interface ChildActiveSession {
/** Session plan ID */
planId: string
/** Session status */
status: string
/** Number of completed problems */
completedSlots: number
/** Total number of problems */
totalSlots: number
/** When the session started (ISO string) */
startedAt: string
}
/**
* Hook: Subscribe to real-time session updates for children via WebSocket
*
* Instead of polling every 10 seconds, this hook:
* 1. Fetches initial session state for all children
* 2. Subscribes to real-time session-started/session-ended events
* 3. Maintains a Map of playerId -> session info that updates instantly
*
* @param userId - The parent's user ID (for socket subscription)
* @param childIds - Array of player IDs to subscribe to
* @returns sessionMap (playerId -> session), isLoading, connected
*/
export function useChildSessionsSocket(userId: string | undefined, childIds: string[]) {
// Session state maintained from socket events
const [sessions, setSessions] = useState<Map<string, ChildActiveSession>>(new Map())
const [isLoading, setIsLoading] = useState(true)
const [connected, setConnected] = useState(false)
const socketRef = useRef<Socket | null>(null)
const subscribedIdsRef = useRef<Set<string>>(new Set())
// Fetch initial session state for a child
const fetchSession = useCallback(async (playerId: string): Promise<ChildActiveSession | null> => {
try {
const res = await api(`players/${playerId}/active-session`)
if (!res.ok) {
if (res.status === 403) return null // Not authorized
throw new Error('Failed to fetch session')
}
const data = await res.json()
if (!data.session) return null
// Convert API response to ChildActiveSession format
return {
planId: data.session.sessionId,
status: data.session.status,
completedSlots: data.session.completedProblems,
totalSlots: data.session.totalProblems,
startedAt: new Date().toISOString(), // API doesn't return startedAt, use now as fallback
}
} catch (error) {
console.error(`[ChildSessionsSocket] Failed to fetch session for ${playerId}:`, error)
return null
}
}, [])
// Fetch initial state for all children
useEffect(() => {
if (childIds.length === 0) {
setIsLoading(false)
setSessions(new Map())
return
}
let cancelled = false
async function fetchAll() {
setIsLoading(true)
const newSessions = new Map<string, ChildActiveSession>()
await Promise.all(
childIds.map(async (playerId) => {
const session = await fetchSession(playerId)
if (session && !cancelled) {
newSessions.set(playerId, session)
}
})
)
if (!cancelled) {
setSessions(newSessions)
setIsLoading(false)
}
}
fetchAll()
return () => {
cancelled = true
}
}, [childIds, fetchSession])
// Setup socket connection and subscriptions
useEffect(() => {
if (!userId || childIds.length === 0) return
// Create socket connection
const socket = io({
path: '/api/socket',
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
})
socketRef.current = socket
socket.on('connect', () => {
console.log('[ChildSessionsSocket] Connected')
setConnected(true)
// Subscribe to child sessions
socket.emit('subscribe-child-sessions', { userId, childIds })
subscribedIdsRef.current = new Set(childIds)
})
socket.on('disconnect', () => {
console.log('[ChildSessionsSocket] Disconnected')
setConnected(false)
})
// Handle session started
socket.on('session-started', (data: SessionStartedEvent) => {
console.log(
'[ChildSessionsSocket] Session started:',
data.playerName,
'session:',
data.sessionId
)
// Fetch the full session details (including progress)
fetchSession(data.playerId).then((session) => {
if (session) {
setSessions((prev) => {
const next = new Map(prev)
next.set(data.playerId, session)
return next
})
}
})
})
// Handle session ended
socket.on('session-ended', (data: SessionEndedEvent) => {
console.log('[ChildSessionsSocket] Session ended:', data.playerName, 'reason:', data.reason)
setSessions((prev) => {
const next = new Map(prev)
next.delete(data.playerId)
return next
})
})
// Cleanup on unmount
return () => {
if (subscribedIdsRef.current.size > 0) {
socket.emit('unsubscribe-child-sessions', {
userId,
childIds: Array.from(subscribedIdsRef.current),
})
}
socket.disconnect()
socketRef.current = null
subscribedIdsRef.current.clear()
}
}, [userId, childIds, fetchSession])
// Handle changes to childIds (subscribe to new, unsubscribe from removed)
useEffect(() => {
const socket = socketRef.current
if (!socket || !connected || !userId) return
const currentIds = new Set(childIds)
const subscribedIds = subscribedIdsRef.current
// Find new IDs to subscribe
const toSubscribe = childIds.filter((id) => !subscribedIds.has(id))
// Find removed IDs to unsubscribe
const toUnsubscribe = Array.from(subscribedIds).filter((id) => !currentIds.has(id))
if (toSubscribe.length > 0) {
socket.emit('subscribe-child-sessions', { userId, childIds: toSubscribe })
toSubscribe.forEach((id) => subscribedIds.add(id))
}
if (toUnsubscribe.length > 0) {
socket.emit('unsubscribe-child-sessions', { userId, childIds: toUnsubscribe })
toUnsubscribe.forEach((id) => subscribedIds.delete(id))
// Remove sessions for unsubscribed children
setSessions((prev) => {
const next = new Map(prev)
toUnsubscribe.forEach((id) => next.delete(id))
return next
})
}
}, [childIds, connected, userId])
// Memoize the return value to avoid unnecessary re-renders
const result = useMemo(
() => ({
sessionMap: sessions,
isLoading,
connected,
}),
[sessions, isLoading, connected]
)
return result
}

View File

@@ -3,7 +3,6 @@
import { useCallback, useEffect, useRef } from 'react'
import { io, type Socket } from 'socket.io-client'
import type { BroadcastState } from '@/components/practice'
import { useStudentPresence } from './useClassroom'
import type {
AbacusControlEvent,
PracticeStateEvent,
@@ -42,13 +41,13 @@ export interface UseSessionBroadcastOptions {
/**
* Hook to broadcast practice session state to observers via WebSocket
*
* Only broadcasts if the student is currently present in a classroom.
* This enables teachers to observe student practice in real-time.
* Broadcasts to any observer (teacher in classroom or parent at home).
* Always connects when there's an active session - observers join the channel.
*
* @param sessionId - The session plan ID
* @param playerId - The student's player ID
* @param state - Current practice state (or null if not in active practice)
* @param options - Optional callbacks for receiving teacher control events
* @param options - Optional callbacks for receiving observer control events
*/
export function useSessionBroadcast(
sessionId: string | undefined,
@@ -66,10 +65,6 @@ export function useSessionBroadcast(
const optionsRef = useRef(options)
optionsRef.current = options
// Check if student is present in a classroom
const { data: presence } = useStudentPresence(playerId)
const isInClassroom = !!presence?.classroomId
// Helper to broadcast current state
const broadcastState = useCallback(() => {
const currentState = stateRef.current
@@ -99,13 +94,14 @@ export function useSessionBroadcast(
})
}, [sessionId])
// Connect to socket and join session channel when in classroom with active session
// Connect to socket and join session channel when there's an active session
// This enables both teachers (classroom) and parents (home) to observe
useEffect(() => {
// Only connect if we have a session and the student is in a classroom
if (!sessionId || !playerId || !isInClassroom) {
// Only connect if we have a session
if (!sessionId || !playerId) {
// Clean up if we were connected
if (socketRef.current) {
console.log('[SessionBroadcast] Disconnecting - no longer in classroom or session ended')
console.log('[SessionBroadcast] Disconnecting - session ended')
socketRef.current.disconnect()
socketRef.current = null
isConnectedRef.current = false
@@ -193,7 +189,7 @@ export function useSessionBroadcast(
socketRef.current = null
isConnectedRef.current = false
}
}, [sessionId, playerId, isInClassroom, broadcastState])
}, [sessionId, playerId, broadcastState])
// Broadcast state changes
useEffect(() => {
@@ -209,6 +205,6 @@ export function useSessionBroadcast(
return {
isConnected: isConnectedRef.current,
isBroadcasting: isConnectedRef.current && isInClassroom && !!state,
isBroadcasting: isConnectedRef.current && !!state,
}
}

View File

@@ -82,15 +82,17 @@ interface UseSessionObserverResult {
* Hook to observe a student's practice session in real-time
*
* Connects to the session's socket channel and receives practice state updates.
* Use this in a teacher's observation modal to see what the student is doing.
* Use this in a teacher's or parent's observation modal to see what the student is doing.
*
* @param sessionId - The session plan ID to observe
* @param observerId - Unique identifier for this observer (e.g., teacher's user ID)
* @param observerId - Unique identifier for this observer (e.g., teacher's/parent's user ID)
* @param playerId - The player ID being observed (for authorization check)
* @param enabled - Whether to start observing (default: true)
*/
export function useSessionObserver(
sessionId: string | undefined,
observerId: string | undefined,
playerId: string | undefined,
enabled = true
): UseSessionObserverResult {
const [state, setState] = useState<ObservedSessionState | null>(null)
@@ -112,7 +114,7 @@ export function useSessionObserver(
}, [sessionId])
useEffect(() => {
if (!sessionId || !observerId || !enabled) {
if (!sessionId || !observerId || !playerId || !enabled) {
// Clean up if disabled
if (socketRef.current) {
stopObserving()
@@ -134,8 +136,8 @@ export function useSessionObserver(
setIsConnected(true)
setError(null)
// Join the session channel as an observer
socket.emit('observe-session', { sessionId, observerId })
// Join the session channel as an observer (includes playerId for authorization)
socket.emit('observe-session', { sessionId, observerId, playerId })
setIsObserving(true)
})
@@ -150,6 +152,13 @@ export function useSessionObserver(
setError('Failed to connect to session')
})
// Handle authorization errors from server
socket.on('observe-error', (data: { error: string }) => {
console.error('[SessionObserver] Observation error:', data.error)
setError(data.error)
setIsObserving(false)
})
// Listen for practice state updates from the student
socket.on('practice-state', (data: PracticeStateEvent) => {
console.log('[SessionObserver] Received practice-state:', {
@@ -182,7 +191,7 @@ export function useSessionObserver(
socket.disconnect()
socketRef.current = null
}
}, [sessionId, observerId, enabled, stopObserving])
}, [sessionId, observerId, playerId, enabled, stopObserving])
// Send control action to student's abacus
const sendControl = useCallback(

View File

@@ -0,0 +1,67 @@
'use client'
import { useMemo } from 'react'
import type { StudentRelationship, EnrollmentStatus } from '@/types/student'
import {
useMyClassroom,
useEnrolledStudents,
useClassroomPresence,
} from '@/hooks/useClassroom'
import { usePlayersWithSkillData } from '@/hooks/useUserPlayers'
/**
* Hook to compute the current user's relationship with a specific student/player.
*
* Determines:
* - isMyChild: whether the player belongs to the current user (parent relationship)
* - isEnrolled: whether the player is enrolled in the user's classroom (teacher relationship)
* - isPresent: whether the player is currently present in the classroom
* - enrollmentStatus: any pending enrollment request
*
* @param playerId - The player ID to check relationship for
* @returns StudentRelationship object with all relationship indicators
*/
export function useStudentRelationship(playerId: string): {
relationship: StudentRelationship
isLoading: boolean
} {
// Get current user's classroom (if they're a teacher)
const { data: classroom, isLoading: isLoadingClassroom } = useMyClassroom()
// Get current user's children (players linked to them)
const { data: myChildren = [], isLoading: isLoadingChildren } = usePlayersWithSkillData()
// Get enrolled students in the teacher's classroom
const { data: enrolledStudents = [], isLoading: isLoadingEnrolled } = useEnrolledStudents(
classroom?.id
)
// Get present students in the classroom
const { data: presentStudents = [], isLoading: isLoadingPresence } = useClassroomPresence(
classroom?.id
)
const relationship = useMemo<StudentRelationship>(() => {
const isMyChild = myChildren.some((child) => child.id === playerId)
const isEnrolled = enrolledStudents.some((student) => student.id === playerId)
const isPresent = presentStudents.some((student) => student.id === playerId)
// TODO: Look up pending enrollment requests for this player
const enrollmentStatus: EnrollmentStatus = isEnrolled ? 'enrolled' : null
return {
isMyChild,
isEnrolled,
isPresent,
enrollmentStatus,
}
}, [playerId, myChildren, enrolledStudents, presentStudents])
const isLoading =
isLoadingClassroom || isLoadingChildren || isLoadingEnrolled || isLoadingPresence
return {
relationship,
isLoading,
}
}

View File

@@ -0,0 +1,70 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import type { StudentStakeholders, ViewerRelationshipSummary } from '@/types/student'
/**
* Response from the stakeholders API
*/
interface StakeholdersResponse {
stakeholders: StudentStakeholders
viewerRelationship: ViewerRelationshipSummary
}
/**
* Query key factory for stakeholders queries
*/
export const stakeholdersKeys = {
all: ['stakeholders'] as const,
player: (playerId: string) => [...stakeholdersKeys.all, playerId] as const,
}
/**
* Fetch stakeholders data for a player
*/
async function fetchStakeholders(playerId: string): Promise<StakeholdersResponse> {
const response = await fetch(`/api/players/${playerId}/stakeholders`)
if (!response.ok) {
throw new Error('Failed to fetch stakeholders')
}
return response.json()
}
/**
* Hook to get complete stakeholder information for a student
*
* Returns:
* - stakeholders: All parents, classrooms, pending requests, presence
* - viewerRelationship: Summary of the current user's relationship to this student
*
* @param playerId - The player/student ID
* @param options - React Query options
*/
export function useStudentStakeholders(
playerId: string | null | undefined,
options?: {
enabled?: boolean
staleTime?: number
refetchInterval?: number | false
}
) {
return useQuery({
queryKey: stakeholdersKeys.player(playerId ?? ''),
queryFn: () => fetchStakeholders(playerId!),
enabled: !!playerId && options?.enabled !== false,
staleTime: options?.staleTime ?? 30_000, // 30 seconds
refetchInterval: options?.refetchInterval,
})
}
/**
* Simplified hook that only returns stakeholders (without loading/error states)
* Useful for components that only need the data when available
*/
export function useStakeholdersData(playerId: string | null | undefined) {
const { data } = useStudentStakeholders(playerId)
return {
stakeholders: data?.stakeholders ?? null,
viewerRelationship: data?.viewerRelationship ?? null,
}
}

View File

@@ -1,6 +1,9 @@
'use client'
import { useMemo } from 'react'
import { useMemo, useRef } from 'react'
// Stable empty array reference to avoid re-renders
const EMPTY_CHILD_IDS: string[] = []
import {
useMyClassroom,
useEnrolledStudents,
@@ -9,11 +12,8 @@ import {
type ActiveSessionInfo,
type PresenceStudent,
} from '@/hooks/useClassroom'
import {
usePlayersWithSkillData,
useChildrenActiveSessions,
type ChildActiveSession,
} from '@/hooks/useUserPlayers'
import { useChildSessionsSocket } from '@/hooks/useChildSessionsSocket'
import { usePlayersWithSkillData } from '@/hooks/useUserPlayers'
import type { StudentWithSkillData } from '@/utils/studentGrouping'
import type { UnifiedStudent, StudentRelationship, StudentActivity } from '@/types/student'
@@ -43,9 +43,13 @@ export interface UseUnifiedStudentsResult {
* - Active sessions (practicing status)
*
* Returns a unified list with relationship and activity status for each student.
*
* @param initialPlayers - Server-prefetched player data
* @param userId - The current user's database ID (for parent session subscriptions)
*/
export function useUnifiedStudents(
initialPlayers?: StudentWithSkillData[]
initialPlayers?: StudentWithSkillData[],
userId?: string
): UseUnifiedStudentsResult {
// Get classroom data (determines if user is a teacher)
const { data: classroom, isLoading: isLoadingClassroom } = useMyClassroom()
@@ -56,10 +60,8 @@ export function useUnifiedStudents(
initialData: initialPlayers,
})
// Get active sessions for my children (polls every 10s)
// Get child IDs for parent session subscription
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(
@@ -71,10 +73,17 @@ export function useUnifiedStudents(
classroom?.id
)
// Get active sessions in classroom (teachers only)
// Get active sessions (teachers only - for students in their classroom)
const { data: activeSessions = [], isLoading: isLoadingActiveSessions } =
useActiveSessionsInClassroom(classroom?.id)
// Get active sessions for parent's children (via WebSocket for real-time updates)
// Only active for non-teachers (parents) who have children
const { sessionMap: childSessionMap, isLoading: isLoadingChildSessions } = useChildSessionsSocket(
!isTeacher ? userId : undefined,
!isTeacher ? childIds : EMPTY_CHILD_IDS
)
// Build lookup maps for efficient merging
const presenceMap = useMemo(() => {
const map = new Map<string, PresenceStudent>()
@@ -84,13 +93,30 @@ export function useUnifiedStudents(
return map
}, [presentStudents])
// Merge teacher's classroom sessions with parent's child sessions
const sessionMap = useMemo(() => {
const map = new Map<string, ActiveSessionInfo>()
// Teacher sessions from classroom
for (const session of activeSessions) {
map.set(session.playerId, session)
}
// Parent sessions from WebSocket (convert ChildActiveSession to ActiveSessionInfo format)
for (const [playerId, session] of childSessionMap) {
if (!map.has(playerId)) {
map.set(playerId, {
sessionId: session.planId,
playerId,
startedAt: session.startedAt,
completedProblems: session.completedSlots,
totalProblems: session.totalSlots,
currentPartIndex: 0,
currentSlotIndex: session.completedSlots,
totalParts: 1,
})
}
}
return map
}, [activeSessions])
}, [activeSessions, childSessionMap])
const enrolledIds = useMemo(() => {
return new Set(enrolledStudents.map((s) => s.id))
@@ -109,11 +135,7 @@ export function useUnifiedStudents(
enrollmentStatus: null, // TODO: Add pending enrollment lookup
}
// 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 session = sessionMap.get(child.id)
const activity: StudentActivity = session
? {
status: 'practicing',
@@ -204,14 +226,13 @@ export function useUnifiedStudents(
enrolledIds,
presenceMap,
sessionMap,
childSessionMap,
])
const isLoading =
isLoadingClassroom ||
isLoadingChildren ||
isLoadingChildSessions ||
(isTeacher && (isLoadingEnrolled || isLoadingPresence || isLoadingActiveSessions))
(isTeacher && (isLoadingEnrolled || isLoadingPresence || isLoadingActiveSessions)) ||
(!isTeacher && isLoadingChildSessions)
return {
students,
@@ -227,17 +248,30 @@ export function useUnifiedStudents(
*/
export function filterStudentsByView(
students: UnifiedStudent[],
view: 'all' | 'my-children' | 'enrolled' | 'in-classroom'
view:
| 'all'
| 'my-children'
| 'my-children-active'
| 'enrolled'
| 'in-classroom'
| 'in-classroom-active'
| 'needs-attention'
): UnifiedStudent[] {
switch (view) {
case 'all':
return students
case 'needs-attention':
return students.filter((s) => s.intervention != null && !s.isArchived)
case 'my-children':
return students.filter((s) => s.relationship.isMyChild)
case 'my-children-active':
return students.filter((s) => s.relationship.isMyChild && s.activity?.status === 'practicing')
case 'enrolled':
return students.filter((s) => s.relationship.isEnrolled)
case 'in-classroom':
return students.filter((s) => s.relationship.isPresent)
case 'in-classroom-active':
return students.filter((s) => s.relationship.isPresent && s.activity?.status === 'practicing')
default:
return students
}
@@ -249,15 +283,44 @@ export function filterStudentsByView(
export function computeViewCounts(
students: UnifiedStudent[],
isTeacher: boolean
): Partial<Record<'all' | 'my-children' | 'enrolled' | 'in-classroom', number>> {
const counts: Partial<Record<'all' | 'my-children' | 'enrolled' | 'in-classroom', number>> = {
): Partial<
Record<
| 'all'
| 'my-children'
| 'my-children-active'
| 'enrolled'
| 'in-classroom'
| 'in-classroom-active'
| 'needs-attention',
number
>
> {
const counts: Partial<
Record<
| 'all'
| 'my-children'
| 'my-children-active'
| 'enrolled'
| 'in-classroom'
| 'in-classroom-active'
| 'needs-attention',
number
>
> = {
all: students.length,
'needs-attention': students.filter((s) => s.intervention != null && !s.isArchived).length,
'my-children': students.filter((s) => s.relationship.isMyChild).length,
'my-children-active': students.filter(
(s) => s.relationship.isMyChild && s.activity?.status === 'practicing'
).length,
}
if (isTeacher) {
counts.enrolled = students.filter((s) => s.relationship.isEnrolled).length
counts['in-classroom'] = students.filter((s) => s.relationship.isPresent).length
counts['in-classroom-active'] = students.filter(
(s) => s.relationship.isPresent && s.activity?.status === 'practicing'
).length
}
return counts

View File

@@ -1,12 +1,6 @@
'use client'
import {
useMutation,
useQueries,
useQuery,
useQueryClient,
useSuspenseQuery,
} from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import type { Player } from '@/db/schema/players'
import { api } from '@/lib/queryClient'
import { playerKeys } from '@/lib/queryKeys'
@@ -339,73 +333,3 @@ 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

@@ -271,3 +271,54 @@ export async function emitSessionEnded(
console.error('[SocketEmitter] Failed to emit session-ended:', error)
}
}
/**
* Emit a session started event to the player channel
*
* Use when: A student starts a practice session.
* This notifies parents who are subscribed to their child's player channel.
*/
export async function emitSessionStartedToPlayer(payload: SessionEventPayload): Promise<void> {
const io = await getSocketIO()
if (!io) return
const eventData: SessionStartedEvent = {
sessionId: payload.sessionId,
playerId: payload.playerId,
playerName: payload.playerName,
}
try {
io.to(`player:${payload.playerId}`).emit('session-started', eventData)
console.log(`[SocketEmitter] session-started -> player:${payload.playerId}`)
} catch (error) {
console.error('[SocketEmitter] Failed to emit session-started to player:', error)
}
}
/**
* Emit a session ended event to the player channel
*
* Use when: A student's practice session ends.
* This notifies parents who are subscribed to their child's player channel.
*/
export async function emitSessionEndedToPlayer(
payload: SessionEventPayload & { reason: 'completed' | 'ended_early' | 'abandoned' }
): Promise<void> {
const io = await getSocketIO()
if (!io) return
const eventData: SessionEndedEvent = {
sessionId: payload.sessionId,
playerId: payload.playerId,
playerName: payload.playerName,
reason: payload.reason,
}
try {
io.to(`player:${payload.playerId}`).emit('session-ended', eventData)
console.log(`[SocketEmitter] session-ended (${payload.reason}) -> player:${payload.playerId}`)
} catch (error) {
console.error('[SocketEmitter] Failed to emit session-ended to player:', error)
}
}

View File

@@ -15,7 +15,6 @@ 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

View File

@@ -17,6 +17,7 @@ import { getRoomActivePlayers, getRoomPlayerIds } from './lib/arcade/player-mana
import { getValidator, type GameName } from './lib/arcade/validators'
import type { GameMove } from './lib/arcade/validation/types'
import { getGameConfig } from './lib/arcade/game-config-helpers'
import { canPerformAction, isParentOf } from './lib/classroom'
// Yjs server-side imports
import * as Y from 'yjs'
@@ -771,10 +772,31 @@ export function initializeSocketServer(httpServer: HTTPServer) {
})
// Session Observation: Start observing a practice session
// Now requires playerId for authorization check (parent or teacher-present)
socket.on(
'observe-session',
async ({ sessionId, observerId }: { sessionId: string; observerId: string }) => {
async ({
sessionId,
observerId,
playerId,
}: {
sessionId: string
observerId: string
playerId?: string
}) => {
try {
// Authorization check: require 'observe' permission (parent or teacher-present)
if (playerId) {
const canObserve = await canPerformAction(observerId, playerId, 'observe')
if (!canObserve) {
console.log(
`⚠️ Observation denied - ${observerId} not authorized for player ${playerId}`
)
socket.emit('observe-error', { error: 'Not authorized to observe this session' })
return
}
}
await socket.join(`session:${sessionId}`)
console.log(`👁️ Observer ${observerId} started watching session: ${sessionId}`)
@@ -782,6 +804,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
socket.to(`session:${sessionId}`).emit('observer-joined', { observerId })
} catch (error) {
console.error('Error starting session observation:', error)
socket.emit('observe-error', { error: 'Failed to start observation' })
}
}
)
@@ -927,6 +950,43 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}
)
// Parent Observation: Subscribe to child session events
// Parents join player:${childId} channels to receive session-started/session-ended events
socket.on(
'subscribe-child-sessions',
async ({ userId, childIds }: { userId: string; childIds: string[] }) => {
try {
for (const childId of childIds) {
// Verify parent-child relationship
const isParent = await isParentOf(userId, childId)
if (isParent) {
await socket.join(`player:${childId}`)
console.log(`👪 Parent ${userId} subscribed to child sessions: ${childId}`)
} else {
console.log(`⚠️ Parent subscription denied - ${userId} is not parent of ${childId}`)
}
}
} catch (error) {
console.error('Error subscribing to child sessions:', error)
}
}
)
// Parent Observation: Unsubscribe from child session events
socket.on(
'unsubscribe-child-sessions',
async ({ userId, childIds }: { userId: string; childIds: string[] }) => {
try {
for (const childId of childIds) {
await socket.leave(`player:${childId}`)
console.log(`👪 Parent ${userId} unsubscribed from child sessions: ${childId}`)
}
} catch (error) {
console.error('Error unsubscribing from child sessions:', error)
}
}
)
socket.on('disconnect', () => {
// Don't delete session on disconnect - it persists across devices
})

View File

@@ -146,3 +146,98 @@ export function toUnifiedStudent(player: Player): UnifiedStudent {
activity: createDefaultActivity(),
}
}
// =============================================================================
// Stakeholder Types
// =============================================================================
// Rich relationship data for showing complete stakeholder information.
// These types are used by the stakeholders API and RelationshipCard component.
/**
* Information about a parent linked to a student
*/
export interface ParentInfo {
/** Parent user ID */
id: string
/** Parent's display name */
name: string
/** Parent's email (optional) */
email?: string
/** Whether this parent is the current viewer */
isMe: boolean
}
/**
* Information about a classroom the student is enrolled in
*/
export interface EnrolledClassroomInfo {
/** Classroom ID */
id: string
/** Classroom name */
name: string
/** Teacher's display name */
teacherName: string
/** Whether the current viewer is the teacher of this classroom */
isMyClassroom: boolean
}
/**
* Information about a pending enrollment request
*/
export interface PendingEnrollmentInfo {
/** Enrollment request ID */
id: string
/** Target classroom ID */
classroomId: string
/** Target classroom name */
classroomName: string
/** Teacher's display name */
teacherName: string
/** Who needs to approve: 'teacher' or 'parent' */
pendingApproval: 'teacher' | 'parent'
/** Who initiated the request */
initiatedBy: 'teacher' | 'parent'
}
/**
* Information about current classroom presence
*/
export interface PresenceInfo {
/** Classroom ID where student is present */
classroomId: string
/** Classroom name */
classroomName: string
/** Teacher's display name */
teacherName: string
}
/**
* Complete stakeholder information for a student
*/
export interface StudentStakeholders {
/** All parents linked to this student */
parents: ParentInfo[]
/** All classrooms the student is enrolled in */
enrolledClassrooms: EnrolledClassroomInfo[]
/** All pending enrollment requests */
pendingEnrollments: PendingEnrollmentInfo[]
/** Current classroom presence (if any) */
currentPresence: PresenceInfo | null
}
/**
* Type of relationship the viewer has with a student
*/
export type ViewerRelationType = 'parent' | 'teacher' | 'observer' | 'none'
/**
* Summary of the viewer's relationship with a student
*/
export interface ViewerRelationshipSummary {
/** Primary relationship type */
type: ViewerRelationType
/** Human-readable description (e.g., "Your child", "Enrolled in Math 101") */
description: string
/** Classroom name if relevant (for teacher/observer) */
classroomName?: string
}