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:
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,4 +325,4 @@
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
201
apps/web/src/app/api/players/[id]/stakeholders/route.ts
Normal file
201
apps/web/src/app/api/players/[id]/stakeholders/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'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'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
330
apps/web/src/components/practice/RelationshipBadge.tsx
Normal file
330
apps/web/src/components/practice/RelationshipBadge.tsx
Normal 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 }
|
||||
654
apps/web/src/components/practice/RelationshipCard.tsx
Normal file
654
apps/web/src/components/practice/RelationshipCard.tsx
Normal 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 }
|
||||
308
apps/web/src/components/practice/RelationshipIndicator.tsx
Normal file
308
apps/web/src/components/practice/RelationshipIndicator.tsx
Normal 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 }
|
||||
@@ -71,6 +71,7 @@ function InteractiveSelectorDemo() {
|
||||
<StudentSelector
|
||||
students={sampleStudents}
|
||||
onSelectStudent={(student) => console.log('Selected:', student.name)}
|
||||
onToggleSelection={(student) => console.log('Toggled:', student.name)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
221
apps/web/src/hooks/useChildSessionsSocket.ts
Normal file
221
apps/web/src/hooks/useChildSessionsSocket.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
67
apps/web/src/hooks/useStudentRelationship.ts
Normal file
67
apps/web/src/hooks/useStudentRelationship.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
70
apps/web/src/hooks/useStudentStakeholders.ts
Normal file
70
apps/web/src/hooks/useStudentStakeholders.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user