feat(classroom): implement entry prompts system

Teachers can now send entry prompts to parents requesting their child
enter the classroom. Features include:

- Entry prompts API with create, list, and respond endpoints
- Real-time notifications via WebSocket to parents
- Parent can accept (enters child) or decline prompts
- Configurable expiry time per classroom (default 30 min)
- Classroom name editing in settings popover
- Active sessions API returns sessions for all enrolled students
- E2E and unit tests for the complete feature

Also fixes bug where expired prompts could block creating new prompts.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-29 16:41:47 -06:00
parent ece319738b
commit de39ab52cc
44 changed files with 6719 additions and 65 deletions

View File

@@ -0,0 +1,22 @@
-- Custom SQL migration file, put your code below! --
CREATE TABLE `entry_prompts` (
`id` text PRIMARY KEY NOT NULL,
`teacher_id` text NOT NULL REFERENCES `users`(`id`) ON DELETE CASCADE,
`player_id` text NOT NULL REFERENCES `players`(`id`) ON DELETE CASCADE,
`classroom_id` text NOT NULL REFERENCES `classrooms`(`id`) ON DELETE CASCADE,
`expires_at` integer NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`responded_by` text REFERENCES `users`(`id`),
`responded_at` integer,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_entry_prompts_teacher` ON `entry_prompts` (`teacher_id`);
--> statement-breakpoint
CREATE INDEX `idx_entry_prompts_player` ON `entry_prompts` (`player_id`);
--> statement-breakpoint
CREATE INDEX `idx_entry_prompts_classroom` ON `entry_prompts` (`classroom_id`);
--> statement-breakpoint
CREATE INDEX `idx_entry_prompts_status` ON `entry_prompts` (`status`);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_entry_prompts_unique_pending` ON `entry_prompts` (`player_id`, `classroom_id`) WHERE `status` = 'pending';

View File

@@ -0,0 +1,5 @@
-- Custom SQL migration file, put your code below! --
-- Add entry_prompt_expiry_minutes column to classrooms table
-- Allows teachers to configure their default entry prompt expiry time
-- NULL means use system default (30 minutes)
ALTER TABLE `classrooms` ADD `entry_prompt_expiry_minutes` integer;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -327,9 +327,23 @@
{
"idx": 46,
"version": "6",
"when": 1767052800000,
"when": 1766980800000,
"tag": "0046_session_observation_shares",
"breakpoints": true
},
{
"idx": 47,
"version": "6",
"when": 1767037546552,
"tag": "0047_add_entry_prompts",
"breakpoints": true
},
{
"idx": 48,
"version": "6",
"when": 1767044481301,
"tag": "0048_ambitious_firedrake",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,563 @@
/**
* E2E tests for Entry Prompts feature
*
* Tests the complete flow of teachers sending entry prompts to parents
* to have their children enter the classroom.
*
* Test scenarios:
* - Teacher creates classroom and enrolls student
* - Teacher sends entry prompt to parent
* - Parent accepts/declines prompt
* - Teacher configures entry prompt expiry time
* - Watch Session visible for practicing enrolled students
*/
import { expect, test, type APIRequestContext } from '@playwright/test'
/**
* Helper to get or create a classroom for the teacher
* Teachers can only have one classroom, so this handles both cases
*/
async function getOrCreateClassroom(
request: APIRequestContext,
name: string
): Promise<{ id: string; code: string; entryPromptExpiryMinutes: number | null }> {
// First try to get existing classroom
const getRes = await request.get('/api/classrooms/mine')
if (getRes.ok()) {
const data = await getRes.json()
if (data.classroom) {
return {
id: data.classroom.id,
code: data.classroom.code,
entryPromptExpiryMinutes: data.classroom.entryPromptExpiryMinutes,
}
}
}
// No existing classroom, create one
const createRes = await request.post('/api/classrooms', {
data: { name },
})
if (!createRes.ok()) {
throw new Error(`Failed to create classroom: ${await createRes.text()}`)
}
const { classroom } = await createRes.json()
return {
id: classroom.id,
code: classroom.code,
entryPromptExpiryMinutes: classroom.entryPromptExpiryMinutes,
}
}
test.describe('Entry Prompts', () => {
test.describe('API Endpoints', () => {
test('teacher can create entry prompt for enrolled student', async ({ browser }) => {
// Create two isolated browser contexts (teacher and parent)
const teacherContext = await browser.newContext()
const parentContext = await browser.newContext()
try {
// Teacher: Set up classroom
const teacherPage = await teacherContext.newPage()
await teacherPage.goto('/')
await teacherPage.waitForLoadState('networkidle')
const teacherRequest = teacherPage.request
// Parent: Create player (child)
const parentPage = await parentContext.newPage()
await parentPage.goto('/')
await parentPage.waitForLoadState('networkidle')
const parentRequest = parentPage.request
// Step 1: Parent creates a child
const createPlayerRes = await parentRequest.post('/api/players', {
data: { name: 'Entry Test Child', emoji: '🧒', color: '#4CAF50' },
})
expect(
createPlayerRes.ok(),
`Create player failed: ${await createPlayerRes.text()}`
).toBeTruthy()
const { player } = await createPlayerRes.json()
const childId = player.id
// Step 2: Teacher gets or creates classroom
const classroom = await getOrCreateClassroom(teacherRequest, 'Entry Prompt Test Class')
const classroomId = classroom.id
const classroomCode = classroom.code
// Step 3: Parent enrolls child using classroom code
const lookupRes = await parentRequest.get(`/api/classrooms/code/${classroomCode}`)
expect(lookupRes.ok(), `Lookup classroom failed: ${await lookupRes.text()}`).toBeTruthy()
const enrollRes = await parentRequest.post(
`/api/classrooms/${classroomId}/enrollment-requests`,
{
data: { playerId: childId },
}
)
expect(enrollRes.ok(), `Enroll failed: ${await enrollRes.text()}`).toBeTruthy()
const { request: enrollmentRequest } = await enrollRes.json()
// Step 4: Teacher approves enrollment
const approveRes = await teacherRequest.post(
`/api/classrooms/${classroomId}/enrollment-requests/${enrollmentRequest.id}/approve`,
{ data: {} }
)
expect(
approveRes.ok(),
`Approve enrollment failed: ${await approveRes.text()}`
).toBeTruthy()
// Step 5: Teacher sends entry prompt
const promptRes = await teacherRequest.post(
`/api/classrooms/${classroomId}/entry-prompts`,
{
data: { playerIds: [childId] },
}
)
expect(promptRes.ok(), `Create prompt failed: ${await promptRes.text()}`).toBeTruthy()
const promptData = await promptRes.json()
expect(promptData.created).toBe(1)
expect(promptData.prompts).toHaveLength(1)
expect(promptData.prompts[0].playerId).toBe(childId)
// Cleanup - just delete the player, keep the classroom
await parentRequest.delete(`/api/players/${childId}`)
} finally {
await teacherContext.close()
await parentContext.close()
}
})
test('cannot send prompt to student already present', async ({ browser }) => {
const teacherContext = await browser.newContext()
const parentContext = await browser.newContext()
try {
const teacherPage = await teacherContext.newPage()
await teacherPage.goto('/')
await teacherPage.waitForLoadState('networkidle')
const teacherRequest = teacherPage.request
const parentPage = await parentContext.newPage()
await parentPage.goto('/')
await parentPage.waitForLoadState('networkidle')
const parentRequest = parentPage.request
// Setup: Create child
const { player } = await (
await parentRequest.post('/api/players', {
data: { name: 'Present Child', emoji: '🧒', color: '#4CAF50' },
})
).json()
// Get or create classroom
const classroom = await getOrCreateClassroom(teacherRequest, 'Presence Test Class')
// Enroll child
await parentRequest.post(`/api/classrooms/${classroom.id}/enrollment-requests`, {
data: { playerId: player.id },
})
// Get enrollment request ID and approve
const requestsRes = await teacherRequest.get(
`/api/classrooms/${classroom.id}/enrollment-requests`
)
const { requests } = await requestsRes.json()
const enrollmentRequest = requests.find(
(r: { playerId: string }) => r.playerId === player.id
)
await teacherRequest.post(
`/api/classrooms/${classroom.id}/enrollment-requests/${enrollmentRequest.id}/approve`,
{ data: {} }
)
// Parent enters child into classroom
const enterRes = await parentRequest.post(`/api/classrooms/${classroom.id}/presence`, {
data: { playerId: player.id },
})
expect(enterRes.ok(), `Enter classroom failed: ${await enterRes.text()}`).toBeTruthy()
// Teacher tries to send prompt - should be skipped
const promptRes = await teacherRequest.post(
`/api/classrooms/${classroom.id}/entry-prompts`,
{
data: { playerIds: [player.id] },
}
)
expect(promptRes.ok()).toBeTruthy()
const promptData = await promptRes.json()
expect(promptData.created).toBe(0)
expect(promptData.skipped).toHaveLength(1)
expect(promptData.skipped[0].reason).toBe('already_present')
// Cleanup
await parentRequest.delete(`/api/players/${player.id}`)
} finally {
await teacherContext.close()
await parentContext.close()
}
})
test('parent can accept entry prompt', async ({ browser }) => {
const teacherContext = await browser.newContext()
const parentContext = await browser.newContext()
try {
const teacherPage = await teacherContext.newPage()
await teacherPage.goto('/')
await teacherPage.waitForLoadState('networkidle')
const teacherRequest = teacherPage.request
const parentPage = await parentContext.newPage()
await parentPage.goto('/')
await parentPage.waitForLoadState('networkidle')
const parentRequest = parentPage.request
// Setup: Create child
const { player } = await (
await parentRequest.post('/api/players', {
data: { name: 'Accept Test Child', emoji: '🧒', color: '#4CAF50' },
})
).json()
// Get or create classroom
const classroom = await getOrCreateClassroom(teacherRequest, 'Accept Test Class')
// Enroll and approve
await parentRequest.post(`/api/classrooms/${classroom.id}/enrollment-requests`, {
data: { playerId: player.id },
})
const requestsRes = await teacherRequest.get(
`/api/classrooms/${classroom.id}/enrollment-requests`
)
const { requests } = await requestsRes.json()
const enrollmentRequest = requests.find(
(r: { playerId: string }) => r.playerId === player.id
)
await teacherRequest.post(
`/api/classrooms/${classroom.id}/enrollment-requests/${enrollmentRequest.id}/approve`,
{ data: {} }
)
// Teacher sends entry prompt
const promptRes = await teacherRequest.post(
`/api/classrooms/${classroom.id}/entry-prompts`,
{
data: { playerIds: [player.id] },
}
)
const { prompts } = await promptRes.json()
const promptId = prompts[0].id
// Parent accepts prompt
const acceptRes = await parentRequest.post(`/api/entry-prompts/${promptId}/respond`, {
data: { action: 'accept' },
})
expect(acceptRes.ok(), `Accept prompt failed: ${await acceptRes.text()}`).toBeTruthy()
const acceptData = await acceptRes.json()
expect(acceptData.action).toBe('accepted')
// Verify child is now present
const presenceRes = await teacherRequest.get(`/api/classrooms/${classroom.id}/presence`)
expect(presenceRes.ok()).toBeTruthy()
const presenceData = await presenceRes.json()
const childPresent = presenceData.students.some((s: { id: string }) => s.id === player.id)
expect(childPresent).toBe(true)
// Cleanup
await parentRequest.delete(`/api/players/${player.id}`)
} finally {
await teacherContext.close()
await parentContext.close()
}
})
test('parent can decline entry prompt', async ({ browser }) => {
const teacherContext = await browser.newContext()
const parentContext = await browser.newContext()
try {
const teacherPage = await teacherContext.newPage()
await teacherPage.goto('/')
await teacherPage.waitForLoadState('networkidle')
const teacherRequest = teacherPage.request
const parentPage = await parentContext.newPage()
await parentPage.goto('/')
await parentPage.waitForLoadState('networkidle')
const parentRequest = parentPage.request
// Setup: Create child
const { player } = await (
await parentRequest.post('/api/players', {
data: { name: 'Decline Test Child', emoji: '🧒', color: '#4CAF50' },
})
).json()
// Get or create classroom
const classroom = await getOrCreateClassroom(teacherRequest, 'Decline Test Class')
// Enroll and approve
await parentRequest.post(`/api/classrooms/${classroom.id}/enrollment-requests`, {
data: { playerId: player.id },
})
const requestsRes = await teacherRequest.get(
`/api/classrooms/${classroom.id}/enrollment-requests`
)
const { requests } = await requestsRes.json()
const enrollmentRequest = requests.find(
(r: { playerId: string }) => r.playerId === player.id
)
await teacherRequest.post(
`/api/classrooms/${classroom.id}/enrollment-requests/${enrollmentRequest.id}/approve`,
{ data: {} }
)
// Teacher sends entry prompt
const promptRes = await teacherRequest.post(
`/api/classrooms/${classroom.id}/entry-prompts`,
{
data: { playerIds: [player.id] },
}
)
const { prompts } = await promptRes.json()
const promptId = prompts[0].id
// Parent declines prompt
const declineRes = await parentRequest.post(`/api/entry-prompts/${promptId}/respond`, {
data: { action: 'decline' },
})
expect(declineRes.ok(), `Decline prompt failed: ${await declineRes.text()}`).toBeTruthy()
const declineData = await declineRes.json()
expect(declineData.action).toBe('declined')
// Verify child is NOT present
const presenceRes = await teacherRequest.get(`/api/classrooms/${classroom.id}/presence`)
expect(presenceRes.ok()).toBeTruthy()
const presenceData = await presenceRes.json()
const childPresent = presenceData.students.some((s: { id: string }) => s.id === player.id)
expect(childPresent).toBe(false)
// Cleanup
await parentRequest.delete(`/api/players/${player.id}`)
} finally {
await teacherContext.close()
await parentContext.close()
}
})
})
test.describe('Classroom Settings', () => {
test('teacher can configure entry prompt expiry time', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
const request = page.request
// Get or create classroom
const classroom = await getOrCreateClassroom(request, 'Settings Test Class')
// Update expiry setting to 60 minutes
const updateRes = await request.patch(`/api/classrooms/${classroom.id}`, {
data: { entryPromptExpiryMinutes: 60 },
})
expect(updateRes.ok(), `Update failed: ${await updateRes.text()}`).toBeTruthy()
const { classroom: updated } = await updateRes.json()
expect(updated.entryPromptExpiryMinutes).toBe(60)
// Update to a different value
const update2Res = await request.patch(`/api/classrooms/${classroom.id}`, {
data: { entryPromptExpiryMinutes: 15 },
})
expect(update2Res.ok()).toBeTruthy()
const { classroom: updated2 } = await update2Res.json()
expect(updated2.entryPromptExpiryMinutes).toBe(15)
// Reset to default (null)
const resetRes = await request.patch(`/api/classrooms/${classroom.id}`, {
data: { entryPromptExpiryMinutes: null },
})
expect(resetRes.ok()).toBeTruthy()
const { classroom: reset } = await resetRes.json()
expect(reset.entryPromptExpiryMinutes).toBeNull()
})
test('entry prompt uses classroom expiry setting', async ({ browser }) => {
const teacherContext = await browser.newContext()
const parentContext = await browser.newContext()
try {
const teacherPage = await teacherContext.newPage()
await teacherPage.goto('/')
await teacherPage.waitForLoadState('networkidle')
const teacherRequest = teacherPage.request
const parentPage = await parentContext.newPage()
await parentPage.goto('/')
await parentPage.waitForLoadState('networkidle')
const parentRequest = parentPage.request
// Setup: Create child
const { player } = await (
await parentRequest.post('/api/players', {
data: { name: 'Expiry Test Child', emoji: '🧒', color: '#4CAF50' },
})
).json()
// Get or create classroom
const classroom = await getOrCreateClassroom(teacherRequest, 'Expiry Test Class')
// Set classroom expiry to 90 minutes
await teacherRequest.patch(`/api/classrooms/${classroom.id}`, {
data: { entryPromptExpiryMinutes: 90 },
})
// Enroll and approve
await parentRequest.post(`/api/classrooms/${classroom.id}/enrollment-requests`, {
data: { playerId: player.id },
})
const requestsRes = await teacherRequest.get(
`/api/classrooms/${classroom.id}/enrollment-requests`
)
const { requests } = await requestsRes.json()
const enrollmentRequest = requests.find(
(r: { playerId: string }) => r.playerId === player.id
)
await teacherRequest.post(
`/api/classrooms/${classroom.id}/enrollment-requests/${enrollmentRequest.id}/approve`,
{ data: {} }
)
// Send entry prompt - should use 90 minute expiry
const promptRes = await teacherRequest.post(
`/api/classrooms/${classroom.id}/entry-prompts`,
{
data: { playerIds: [player.id] },
}
)
expect(promptRes.ok()).toBeTruthy()
const { prompts } = await promptRes.json()
// Verify expiry is approximately 90 minutes from now
const expiresAt = new Date(prompts[0].expiresAt)
const now = new Date()
const diffMinutes = (expiresAt.getTime() - now.getTime()) / (60 * 1000)
// Allow some tolerance for test execution time
expect(diffMinutes).toBeGreaterThan(88)
expect(diffMinutes).toBeLessThan(92)
// Reset classroom setting and cleanup
await teacherRequest.patch(`/api/classrooms/${classroom.id}`, {
data: { entryPromptExpiryMinutes: null },
})
await parentRequest.delete(`/api/players/${player.id}`)
} finally {
await teacherContext.close()
await parentContext.close()
}
})
})
test.describe('Active Sessions for Enrolled Students', () => {
test('active sessions returned for enrolled students not present', async ({ browser }) => {
test.setTimeout(60000) // Increase timeout for this complex test
const teacherContext = await browser.newContext()
const parentContext = await browser.newContext()
try {
const teacherPage = await teacherContext.newPage()
await teacherPage.goto('/')
await teacherPage.waitForLoadState('networkidle')
const teacherRequest = teacherPage.request
const parentPage = await parentContext.newPage()
await parentPage.goto('/')
await parentPage.waitForLoadState('networkidle')
const parentRequest = parentPage.request
// Setup: Create child with skills
const { player } = await (
await parentRequest.post('/api/players', {
data: { name: 'Session Test Child', emoji: '🧒', color: '#4CAF50' },
})
).json()
// Enable skills for the player
await parentRequest.put(`/api/curriculum/${player.id}/skills`, {
data: {
masteredSkillIds: ['1a-direct-addition', '1b-heaven-bead', '1c-simple-combinations'],
},
})
// Get or create classroom
const classroom = await getOrCreateClassroom(teacherRequest, 'Session Test Class')
// Enroll and approve
await parentRequest.post(`/api/classrooms/${classroom.id}/enrollment-requests`, {
data: { playerId: player.id },
})
const requestsRes = await teacherRequest.get(
`/api/classrooms/${classroom.id}/enrollment-requests`
)
const { requests } = await requestsRes.json()
const enrollmentRequest = requests.find(
(r: { playerId: string }) => r.playerId === player.id
)
await teacherRequest.post(
`/api/classrooms/${classroom.id}/enrollment-requests/${enrollmentRequest.id}/approve`,
{ data: {} }
)
// Parent starts a practice session for their child (without entering classroom)
const createPlanRes = await parentRequest.post(
`/api/curriculum/${player.id}/sessions/plans`,
{
data: { durationMinutes: 5 },
}
)
expect(createPlanRes.ok(), `Create plan failed: ${await createPlanRes.text()}`).toBeTruthy()
const { plan } = await createPlanRes.json()
// Approve and start the plan
await parentRequest.patch(`/api/curriculum/${player.id}/sessions/plans/${plan.id}`, {
data: { action: 'approve' },
})
await parentRequest.patch(`/api/curriculum/${player.id}/sessions/plans/${plan.id}`, {
data: { action: 'start' },
})
// Teacher checks active sessions - should include this student even though not present
const sessionsRes = await teacherRequest.get(
`/api/classrooms/${classroom.id}/presence/active-sessions`
)
expect(sessionsRes.ok(), `Get sessions failed: ${await sessionsRes.text()}`).toBeTruthy()
const { sessions } = await sessionsRes.json()
// Find the session for our test player
const playerSession = sessions.find((s: { playerId: string }) => s.playerId === player.id)
expect(playerSession).toBeDefined()
expect(playerSession.isPresent).toBe(false) // Not present but session is visible
// Cleanup - abandon session first
await parentRequest.patch(`/api/curriculum/${player.id}/sessions/plans/${plan.id}`, {
data: { action: 'abandon' },
})
await parentRequest.delete(`/api/players/${player.id}`)
} finally {
await teacherContext.close()
await parentContext.close()
}
})
})
})

View File

@@ -0,0 +1,186 @@
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import {
getEnrolledStudents,
getLinkedParentIds,
getPresentPlayerIds,
getTeacherClassroom,
} from '@/lib/classroom'
import { emitEntryPromptCreated } from '@/lib/classroom/socket-emitter'
import { getDbUserId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ classroomId: string }>
}
/**
* Default expiry time for entry prompts (30 minutes)
*/
const DEFAULT_EXPIRY_MINUTES = 30
/**
* GET /api/classrooms/[classroomId]/entry-prompts
* Get pending entry prompts for the classroom (teacher only)
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const userId = await getDbUserId()
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(userId)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get pending prompts for this classroom
const prompts = await db.query.entryPrompts.findMany({
where: and(
eq(schema.entryPrompts.classroomId, classroomId),
eq(schema.entryPrompts.status, 'pending')
),
})
// Filter out expired prompts (client-side check)
const now = new Date()
const activePrompts = prompts.filter((p) => p.expiresAt > now)
return NextResponse.json({ prompts: activePrompts })
} catch (error) {
console.error('Failed to fetch entry prompts:', error)
return NextResponse.json({ error: 'Failed to fetch entry prompts' }, { status: 500 })
}
}
/**
* POST /api/classrooms/[classroomId]/entry-prompts
* Create entry prompts for students (teacher only)
*
* Body: { playerIds: string[], expiresInMinutes?: number }
* Returns: { prompts: EntryPrompt[], skipped: { playerId: string, reason: string }[] }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const userId = await getDbUserId()
const body = await req.json()
// Validate request body
if (!body.playerIds || !Array.isArray(body.playerIds) || body.playerIds.length === 0) {
return NextResponse.json({ error: 'Missing or invalid playerIds' }, { status: 400 })
}
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(userId)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get teacher's name for the notification
const teacher = await db.query.users.findFirst({
where: eq(schema.users.id, userId),
})
const teacherName = teacher?.name || 'Your teacher'
// Get enrolled students for this classroom
const enrolledStudents = await getEnrolledStudents(classroomId)
const enrolledPlayerIds = new Set(enrolledStudents.map((s) => s.id))
// Get currently present students
const presentPlayerIds = new Set(await getPresentPlayerIds(classroomId))
// Get existing pending prompts to avoid duplicates (only non-expired ones)
const now = new Date()
const existingPrompts = await db.query.entryPrompts.findMany({
where: and(
eq(schema.entryPrompts.classroomId, classroomId),
eq(schema.entryPrompts.status, 'pending'),
inArray(schema.entryPrompts.playerId, body.playerIds)
),
})
// Filter out expired prompts - they shouldn't block creating new prompts
const activeExistingPrompts = existingPrompts.filter((p) => p.expiresAt > now)
const existingPromptPlayerIds = new Set(activeExistingPrompts.map((p) => p.playerId))
// Calculate expiry time (request override > classroom setting > system default)
const expiresInMinutes =
body.expiresInMinutes || classroom.entryPromptExpiryMinutes || DEFAULT_EXPIRY_MINUTES
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000)
// Process each player
const createdPrompts: (typeof schema.entryPrompts.$inferSelect)[] = []
const skipped: { playerId: string; reason: string }[] = []
for (const playerId of body.playerIds) {
// Check if enrolled
if (!enrolledPlayerIds.has(playerId)) {
skipped.push({ playerId, reason: 'not_enrolled' })
continue
}
// Check if already present
if (presentPlayerIds.has(playerId)) {
skipped.push({ playerId, reason: 'already_present' })
continue
}
// Check if already has pending prompt
if (existingPromptPlayerIds.has(playerId)) {
skipped.push({ playerId, reason: 'pending_prompt_exists' })
continue
}
// Create the entry prompt
const [prompt] = await db
.insert(schema.entryPrompts)
.values({
teacherId: userId,
playerId,
classroomId,
expiresAt,
})
.returning()
createdPrompts.push(prompt)
// Get player info for the notification
const player = await db.query.players.findFirst({
where: eq(schema.players.id, playerId),
})
if (player) {
// Get parent IDs to notify
const parentIds = await getLinkedParentIds(playerId)
// Emit socket event to parents
await emitEntryPromptCreated(
{
promptId: prompt.id,
classroomId,
classroomName: classroom.name,
playerId,
playerName: player.name,
playerEmoji: player.emoji,
teacherName,
expiresAt,
},
parentIds
)
}
}
return NextResponse.json(
{
prompts: createdPrompts,
skipped,
created: createdPrompts.length,
skippedCount: skipped.length,
},
{ status: 201 }
)
} catch (error) {
console.error('Failed to create entry prompts:', error)
return NextResponse.json({ error: 'Failed to create entry prompts' }, { status: 500 })
}
}

View File

@@ -1,7 +1,7 @@
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getClassroomPresence, getTeacherClassroom } from '@/lib/classroom'
import { getClassroomPresence, getEnrolledStudents, getTeacherClassroom } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
@@ -44,16 +44,19 @@ interface ActiveSessionInfo {
totalProblems: number
/** Number of completed problems */
completedProblems: number
/** Whether the student is currently present in the classroom */
isPresent: boolean
}
/**
* GET /api/classrooms/[classroomId]/presence/active-sessions
* Get active practice sessions for students currently present in the classroom
* Get active practice sessions for enrolled students in the classroom
*
* Returns: { sessions: ActiveSessionInfo[] }
*
* This endpoint allows teachers to see which students are actively practicing
* so they can observe their sessions in real-time.
* This endpoint allows teachers to see which students are actively practicing.
* It returns sessions for ALL enrolled students, not just present ones.
* The `isPresent` field indicates whether the teacher can observe the session.
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
@@ -67,20 +70,25 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
// Get all students currently present in the classroom
const presences = await getClassroomPresence(classroomId)
// Filter out presences where player was deleted (undefined)
const playerIds = presences.filter((p) => p.player !== undefined).map((p) => p.player!.id)
// Get all enrolled students in the classroom
const enrolledStudents = await getEnrolledStudents(classroomId)
const enrolledPlayerIds = enrolledStudents.map((s) => s.id)
if (playerIds.length === 0) {
if (enrolledPlayerIds.length === 0) {
return NextResponse.json({ sessions: [] })
}
// Find active sessions for these players
// Get presence info to know which students are present
const presences = await getClassroomPresence(classroomId)
const presentPlayerIds = new Set(
presences.filter((p) => p.player !== undefined).map((p) => p.player!.id)
)
// Find active sessions for enrolled students
// Active = status is 'in_progress', startedAt is set, completedAt is null
const activeSessions = await db.query.sessionPlans.findMany({
where: and(
inArray(schema.sessionPlans.playerId, playerIds),
inArray(schema.sessionPlans.playerId, enrolledPlayerIds),
eq(schema.sessionPlans.status, 'in_progress'),
isNull(schema.sessionPlans.completedAt)
),
@@ -108,6 +116,7 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
totalParts: parts.length,
totalProblems,
completedProblems,
isPresent: presentPlayerIds.has(session.playerId),
}
})

View File

@@ -56,7 +56,7 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
* PATCH /api/classrooms/[classroomId]
* Update classroom settings (teacher only)
*
* Body: { name?: string, regenerateCode?: boolean }
* Body: { name?: string, regenerateCode?: boolean, entryPromptExpiryMinutes?: number | null }
* Returns: { classroom }
*/
export async function PATCH(req: NextRequest, { params }: RouteParams) {
@@ -81,8 +81,15 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) {
}
// Update other fields
const updates: { name?: string } = {}
const updates: { name?: string; entryPromptExpiryMinutes?: number | null } = {}
if (body.name) updates.name = body.name
// Allow setting to null (use system default) or a positive number
if ('entryPromptExpiryMinutes' in body) {
const value = body.entryPromptExpiryMinutes
if (value === null || (typeof value === 'number' && value > 0)) {
updates.entryPromptExpiryMinutes = value
}
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 })

View File

@@ -0,0 +1,170 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { enterClassroom, isParent } from '@/lib/classroom'
import { emitEntryPromptAccepted, emitEntryPromptDeclined } from '@/lib/classroom/socket-emitter'
import { getDbUserId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ promptId: string }>
}
/**
* POST /api/entry-prompts/[promptId]/respond
* Respond to an entry prompt (parent only)
*
* Body: { action: 'accept' | 'decline' }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { promptId } = await params
const userId = await getDbUserId()
const body = await req.json()
// Validate action
if (!body.action || !['accept', 'decline'].includes(body.action)) {
return NextResponse.json(
{ error: 'Invalid action. Must be "accept" or "decline".' },
{ status: 400 }
)
}
// Get the prompt
const prompt = await db.query.entryPrompts.findFirst({
where: eq(schema.entryPrompts.id, promptId),
})
if (!prompt) {
return NextResponse.json({ error: 'Prompt not found' }, { status: 404 })
}
// Check if prompt is still pending
if (prompt.status !== 'pending') {
return NextResponse.json({ error: 'Prompt has already been responded to' }, { status: 400 })
}
// Check if prompt has expired
if (prompt.expiresAt < new Date()) {
// Mark as expired
await db
.update(schema.entryPrompts)
.set({ status: 'expired' })
.where(eq(schema.entryPrompts.id, promptId))
return NextResponse.json({ error: 'Prompt has expired' }, { status: 400 })
}
// Verify user is a parent of the player
const isParentOfPlayer = await isParent(userId, prompt.playerId)
if (!isParentOfPlayer) {
return NextResponse.json(
{ error: 'Not authorized. Must be a parent of the student.' },
{ status: 403 }
)
}
// Get user info for notifications
const user = await db.query.users.findFirst({
where: eq(schema.users.id, userId),
})
const parentName = user?.name || 'Parent'
// Get player info for notifications
const player = await db.query.players.findFirst({
where: eq(schema.players.id, prompt.playerId),
})
const playerName = player?.name || 'Student'
// Get classroom info for notifications
const classroom = await db.query.classrooms.findFirst({
where: eq(schema.classrooms.id, prompt.classroomId),
})
const classroomName = classroom?.name || 'Classroom'
if (body.action === 'accept') {
// Update prompt status
await db
.update(schema.entryPrompts)
.set({
status: 'accepted',
respondedBy: userId,
respondedAt: new Date(),
})
.where(eq(schema.entryPrompts.id, promptId))
// Enter child into classroom
const enterResult = await enterClassroom({
playerId: prompt.playerId,
classroomId: prompt.classroomId,
enteredBy: userId,
})
if (!enterResult.success) {
// Revert prompt status if enter failed
await db
.update(schema.entryPrompts)
.set({
status: 'pending',
respondedBy: null,
respondedAt: null,
})
.where(eq(schema.entryPrompts.id, promptId))
return NextResponse.json(
{ error: enterResult.error || 'Failed to enter classroom' },
{ status: 400 }
)
}
// Emit socket events
await emitEntryPromptAccepted(
{
promptId,
classroomId: prompt.classroomId,
classroomName,
playerId: prompt.playerId,
playerName,
acceptedBy: parentName,
},
prompt.teacherId
)
return NextResponse.json({
success: true,
action: 'accepted',
message: `${playerName} has been entered into ${classroomName}`,
})
} else {
// Decline the prompt
await db
.update(schema.entryPrompts)
.set({
status: 'declined',
respondedBy: userId,
respondedAt: new Date(),
})
.where(eq(schema.entryPrompts.id, promptId))
// Emit socket event to teacher
await emitEntryPromptDeclined(
{
promptId,
classroomId: prompt.classroomId,
playerId: prompt.playerId,
playerName,
declinedBy: parentName,
},
prompt.teacherId
)
return NextResponse.json({
success: true,
action: 'declined',
message: 'Entry prompt declined',
})
}
} catch (error) {
console.error('Failed to respond to entry prompt:', error)
return NextResponse.json({ error: 'Failed to respond to entry prompt' }, { status: 500 })
}
}

View File

@@ -0,0 +1,75 @@
import { and, eq, gt, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getLinkedChildren } from '@/lib/classroom'
import { getDbUserId } from '@/lib/viewer'
/**
* GET /api/entry-prompts
* Get pending entry prompts for the current user's children (parent view)
*
* Returns active (pending + not expired) prompts for all children linked to the viewer
*/
export async function GET(_req: NextRequest) {
try {
const userId = await getDbUserId()
// Get children linked to this user (parent)
const children = await getLinkedChildren(userId)
if (children.length === 0) {
return NextResponse.json({ prompts: [] })
}
const childIds = children.map((c) => c.id)
// Get pending prompts for these children
const now = new Date()
const prompts = await db.query.entryPrompts.findMany({
where: and(
inArray(schema.entryPrompts.playerId, childIds),
eq(schema.entryPrompts.status, 'pending'),
gt(schema.entryPrompts.expiresAt, now)
),
})
// Get additional info for display (classroom names, player info)
const enrichedPrompts = await Promise.all(
prompts.map(async (prompt) => {
const [classroom, player, teacher] = await Promise.all([
db.query.classrooms.findFirst({
where: eq(schema.classrooms.id, prompt.classroomId),
}),
db.query.players.findFirst({
where: eq(schema.players.id, prompt.playerId),
}),
db.query.users.findFirst({
where: eq(schema.users.id, prompt.teacherId),
}),
])
return {
...prompt,
expiresAt: prompt.expiresAt.toISOString(),
createdAt: prompt.createdAt.toISOString(),
player: {
id: player?.id ?? prompt.playerId,
name: player?.name ?? 'Unknown student',
emoji: player?.emoji ?? '👤',
},
classroom: {
id: classroom?.id ?? prompt.classroomId,
name: classroom?.name ?? 'Unknown classroom',
},
teacher: {
displayName: teacher?.name ?? 'Your teacher',
},
}
})
)
return NextResponse.json({ prompts: enrichedPrompts })
} catch (error) {
console.error('Failed to fetch entry prompts:', error)
return NextResponse.json({ error: 'Failed to fetch entry prompts' }, { status: 500 })
}
}

View File

@@ -2,6 +2,8 @@
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useToast } from '@/components/common/ToastContext'
import {
AddStudentByFamilyCodeModal,
CreateClassroomForm,
@@ -10,8 +12,10 @@ import {
TeacherEnrollmentSection,
} from '@/components/classroom'
import { useClassroomSocket } from '@/hooks/useClassroomSocket'
import { api } from '@/lib/queryClient'
import { PageWithNav } from '@/components/PageWithNav'
import {
EntryPromptBanner,
getAvailableViews,
getDefaultView,
StudentFilterBar,
@@ -54,6 +58,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
const router = useRouter()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { showSuccess, showError } = useToast()
// Classroom state - check if user is a teacher
const { data: classroom, isLoading: isLoadingClassroom } = useMyClassroom()
@@ -230,6 +235,68 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
setSelectedIds(new Set())
}, [selectedIds, updatePlayer])
// Mutation for bulk entry prompts
const bulkEntryPrompt = useMutation({
mutationFn: async (playerIds: string[]) => {
if (!classroomId) throw new Error('No classroom ID')
const response = await api(`classrooms/${classroomId}/entry-prompts`, {
method: 'POST',
body: JSON.stringify({ playerIds }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to send prompts')
}
return response.json()
},
})
// Compute which selected students are eligible for entry prompts
// (enrolled in teacher's classroom but not currently present)
const promptEligibleIds = useMemo(() => {
if (!isTeacher || !classroomId) return new Set<string>()
return new Set(
Array.from(selectedIds).filter((id) => {
const student = unifiedStudents.find((s) => s.id === id)
if (!student) return false
// Must be enrolled but not present
return student.relationship.isEnrolled && !student.relationship.isPresent
})
)
}, [selectedIds, unifiedStudents, isTeacher, classroomId])
// Handle bulk prompt to enter
const handleBulkPromptToEnter = useCallback(async () => {
if (promptEligibleIds.size === 0) return
try {
const result = await bulkEntryPrompt.mutateAsync(Array.from(promptEligibleIds))
// Show success message
const created = result.created ?? promptEligibleIds.size
const skipped = result.skippedCount ?? 0
if (created > 0) {
showSuccess(
'Entry prompts sent',
`Sent to ${created} student${created !== 1 ? 's' : ''}${skipped > 0 ? ` (${skipped} skipped)` : ''}`
)
} else if (skipped > 0) {
showError(
'No prompts sent',
`All ${skipped} students were skipped (already prompted or present)`
)
}
// Clear selection after prompting
setSelectedIds(new Set())
} catch (error) {
showError(
'Failed to send prompts',
error instanceof Error ? error.message : 'An unexpected error occurred'
)
}
}, [promptEligibleIds, bulkEntryPrompt, showSuccess, showError])
// Handle add student - different modal for teachers vs parents
const handleAddStudent = useCallback(() => {
if (isTeacher) {
@@ -316,7 +383,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
onViewChange={setCurrentView}
availableViews={availableViews}
viewCounts={viewCounts}
classroomCode={classroomCode}
classroom={classroom}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
skillFilters={skillFilters}
@@ -327,6 +394,8 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
onAddStudent={handleAddStudent}
selectedCount={selectedIds.size}
onBulkArchive={handleBulkArchive}
onBulkPromptToEnter={isTeacher ? handleBulkPromptToEnter : undefined}
promptEligibleCount={promptEligibleIds.size}
onClearSelection={handleClearSelection}
/>
@@ -380,6 +449,10 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
{/* Pending Enrollment Approvals - for parents to approve teacher-initiated requests */}
<PendingApprovalsSection />
{/* Entry Prompt Banner - for parents to respond to teacher classroom entry requests */}
{/* Shows for anyone with children, even if they're also a teacher */}
<EntryPromptBanner />
{/* Needs Attention Section - uses same bucket styling as other sections */}
{studentsNeedingAttention.length > 0 && (
<div data-bucket="attention" data-component="needs-attention-bucket">
@@ -658,6 +731,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
}}
observerId={userId}
canShare={observingStudent.relationship.isMyChild}
classroomId={classroomId}
/>
)}
</PageWithNav>

View File

@@ -2,6 +2,7 @@
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useState } from 'react'
import { useToast } from '@/components/common/ToastContext'
import { PageWithNav } from '@/components/PageWithNav'
import {
ActiveSession,
@@ -41,6 +42,7 @@ interface PracticeClientProps {
*/
export function PracticeClient({ studentId, player, initialSession }: PracticeClientProps) {
const router = useRouter()
const { showError } = useToast()
// Track pause state for HUD display (ActiveSession owns the modal and actual pause logic)
const [isPaused, setIsPaused] = useState(false)
@@ -97,32 +99,57 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
// Handle recording an answer
const handleAnswer = useCallback(
async (result: Omit<SlotResult, 'timestamp' | 'partNumber'>): Promise<void> => {
const updatedPlan = await recordResult.mutateAsync({
playerId: studentId,
planId: currentPlan.id,
result,
})
try {
const updatedPlan = await recordResult.mutateAsync({
playerId: studentId,
planId: currentPlan.id,
result,
})
// If session just completed, redirect to summary
if (updatedPlan.completedAt) {
router.push(`/practice/${studentId}/summary`, { scroll: false })
// If session just completed, redirect to summary
if (updatedPlan.completedAt) {
router.push(`/practice/${studentId}/summary`, { scroll: false })
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
if (message.includes('Not authorized')) {
showError(
'Not authorized',
'Only parents or teachers with the student present in their classroom can record answers.'
)
} else {
showError('Failed to record answer', message)
}
}
},
[studentId, currentPlan.id, recordResult, router]
[studentId, currentPlan.id, recordResult, router, showError]
)
// Handle ending session early
const handleEndEarly = useCallback(
async (reason?: string) => {
await endEarly.mutateAsync({
playerId: studentId,
planId: currentPlan.id,
reason,
})
// Redirect to summary after ending early
router.push(`/practice/${studentId}/summary`, { scroll: false })
try {
await endEarly.mutateAsync({
playerId: studentId,
planId: currentPlan.id,
reason,
})
// Redirect to summary after ending early
router.push(`/practice/${studentId}/summary`, { scroll: false })
} catch (err) {
// Check if it's an authorization error
const message = err instanceof Error ? err.message : 'Unknown error'
if (message.includes('Not authorized')) {
showError(
'Not authorized',
'Only parents or teachers with the student present in their classroom can end sessions.'
)
} else {
showError('Failed to end session', message)
}
}
},
[studentId, currentPlan.id, endEarly, router]
[studentId, currentPlan.id, endEarly, router, showError]
)
// Handle session completion (called by ActiveSession when all problems done)
@@ -135,11 +162,16 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
// broadcastState is updated by ActiveSession via the onBroadcastStateChange callback
// onAbacusControl receives control events from observing teacher
// onTeacherPause/onTeacherResume receive pause/resume commands from teacher
useSessionBroadcast(currentPlan.id, studentId, broadcastState, {
onAbacusControl: setTeacherControl,
onTeacherPause: setTeacherPauseRequest,
onTeacherResume: () => setTeacherResumeRequest(true),
})
const { sendPartTransition, sendPartTransitionComplete } = useSessionBroadcast(
currentPlan.id,
studentId,
broadcastState,
{
onAbacusControl: setTeacherControl,
onTeacherPause: setTeacherPauseRequest,
onTeacherResume: () => setTeacherResumeRequest(true),
}
)
// Build session HUD data for PracticeSubNav
const sessionHud: SessionHudData | undefined = currentPart
@@ -174,6 +206,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
onPause: handlePause,
onResume: handleResume,
onEndEarly: () => handleEndEarly('Session ended'),
isEndingSession: endEarly.isPending,
isBrowseMode,
onToggleBrowse: () => setIsBrowseMode((prev) => !prev),
onBrowseNavigate: setBrowseIndex,
@@ -240,6 +273,8 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
onTeacherResumeHandled={() => setTeacherResumeRequest(false)}
manualPauseRequest={manualPauseRequest}
onManualPauseHandled={() => setManualPauseRequest(false)}
onPartTransition={sendPartTransition}
onPartTransitionComplete={sendPartTransitionComplete}
/>
</PracticeErrorBoundary>
</main>

View File

@@ -33,6 +33,7 @@ import type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
import type { Player } from '@/db/schema/players'
import type { PracticeSession } from '@/db/schema/practice-sessions'
import type { SessionPlan } from '@/db/schema/session-plans'
import { useMyClassroom } from '@/hooks/useClassroom'
import { usePlayerPresenceSocket } from '@/hooks/usePlayerPresenceSocket'
import { useSessionMode } from '@/hooks/useSessionMode'
import type { SessionMode } from '@/lib/curriculum/session-mode'
@@ -2520,6 +2521,10 @@ export function DashboardClient({
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Get teacher's classroom for entry prompts
const { data: myClassroom } = useMyClassroom()
const classroomId = myClassroom?.id
// React Query: Use server props as initial data, get live updates from cache
const { data: activeSession } = useActiveSessionPlan(studentId, initialActiveSession)
@@ -2888,6 +2893,7 @@ export function DashboardClient({
}}
observerId={userId}
canShare={true}
classroomId={classroomId}
/>
)}
</PracticeErrorBoundary>

View File

@@ -0,0 +1,310 @@
'use client'
import Link from 'next/link'
import { useCallback, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { PageWithNav } from '@/components/PageWithNav'
import { useTheme } from '@/contexts/ThemeContext'
import { useToast } from '@/components/common/ToastContext'
import { api } from '@/lib/queryClient'
import { css } from '../../../../../styled-system/css'
interface StudentNotPresentPageProps {
studentName: string
studentEmoji: string
studentId: string
classroomId: string
}
/**
* Shown to teachers when they try to observe a student who is enrolled
* in their class but is not currently present in the classroom.
*/
export function StudentNotPresentPage({
studentName,
studentEmoji,
studentId,
classroomId,
}: StudentNotPresentPageProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { showSuccess, showError } = useToast()
const [promptSent, setPromptSent] = useState(false)
// Mutation to send entry prompt to parents
const sendEntryPrompt = useMutation({
mutationFn: async () => {
const response = await api(`classrooms/${classroomId}/entry-prompts`, {
method: 'POST',
body: JSON.stringify({ playerIds: [studentId] }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to send prompt')
}
return response.json()
},
onSuccess: (data) => {
if (data.created > 0) {
setPromptSent(true)
showSuccess(
'Entry prompt sent',
`${studentName}'s parent has been notified to enter them into the classroom.`
)
} else if (data.skipped?.length > 0) {
const reason = data.skipped[0]?.reason
if (reason === 'pending_prompt_exists') {
showError('Prompt already pending', `${studentName} already has a pending entry prompt.`)
} else if (reason === 'already_present') {
showSuccess('Already in classroom', `${studentName} is now in the classroom!`)
} else {
showError('Could not send prompt', reason || 'Unknown error')
}
}
},
onError: (error) => {
showError(
'Failed to send prompt',
error instanceof Error ? error.message : 'An unexpected error occurred'
)
},
})
const handleSendPrompt = useCallback(() => {
sendEntryPrompt.mutate()
}, [sendEntryPrompt])
return (
<PageWithNav>
<main
data-component="student-not-present-page"
className={css({
minHeight: 'calc(100vh - 80px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
})}
>
<div
className={css({
maxWidth: '500px',
width: '100%',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '16px',
padding: '2rem',
boxShadow: 'lg',
textAlign: 'center',
})}
>
{/* Student avatar */}
<div
className={css({
fontSize: '4rem',
marginBottom: '1rem',
})}
>
{studentEmoji}
</div>
{/* Title */}
<h1
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.800',
marginBottom: '0.5rem',
})}
>
{studentName} is not in your classroom
</h1>
{/* Explanation */}
<p
className={css({
fontSize: '1rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '1.5rem',
lineHeight: '1.6',
})}
>
{studentName} is enrolled in your class, but you can only observe their practice
sessions when they are present in your classroom.
</p>
{/* Quick action - Send entry prompt */}
{!promptSent ? (
<div
data-element="entry-prompt-section"
className={css({
backgroundColor: isDark ? 'orange.900/30' : 'orange.50',
border: '2px solid',
borderColor: isDark ? 'orange.700' : 'orange.300',
borderRadius: '12px',
padding: '1.25rem',
marginBottom: '1rem',
})}
>
<h2
className={css({
fontSize: '0.9375rem',
fontWeight: '600',
color: isDark ? 'orange.300' : 'orange.700',
marginBottom: '0.5rem',
})}
>
Notify {studentName}&apos;s parent
</h2>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.600',
marginBottom: '1rem',
lineHeight: '1.5',
})}
>
Send a notification to {studentName}&apos;s parent asking them to enter the
classroom.
</p>
<button
type="button"
onClick={handleSendPrompt}
disabled={sendEntryPrompt.isPending}
data-action="send-entry-prompt"
className={css({
width: '100%',
padding: '0.75rem 1rem',
fontSize: '1rem',
fontWeight: '600',
color: 'white',
backgroundColor: isDark ? 'orange.600' : 'orange.500',
borderRadius: '8px',
border: 'none',
cursor: sendEntryPrompt.isPending ? 'wait' : 'pointer',
opacity: sendEntryPrompt.isPending ? 0.7 : 1,
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'orange.500' : 'orange.600',
},
_disabled: {
cursor: 'wait',
opacity: 0.7,
},
})}
>
{sendEntryPrompt.isPending ? 'Sending...' : 'Send Entry Prompt'}
</button>
</div>
) : (
<div
data-element="prompt-sent-confirmation"
className={css({
backgroundColor: isDark ? 'green.900/30' : 'green.50',
border: '2px solid',
borderColor: isDark ? 'green.700' : 'green.300',
borderRadius: '12px',
padding: '1.25rem',
marginBottom: '1rem',
textAlign: 'center',
})}
>
<p
className={css({
fontSize: '0.9375rem',
fontWeight: '500',
color: isDark ? 'green.300' : 'green.700',
})}
>
Entry prompt sent to {studentName}&apos;s parent
</p>
</div>
)}
{/* How to fix it manually */}
<div
className={css({
backgroundColor: isDark ? 'gray.800' : 'gray.100',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
borderRadius: '12px',
padding: '1rem',
marginBottom: '1.5rem',
textAlign: 'left',
})}
>
<h2
className={css({
fontSize: '0.8125rem',
fontWeight: '600',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '0.5rem',
})}
>
Or have {studentName} join manually
</h2>
<ol
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.400' : 'gray.500',
lineHeight: '1.6',
paddingLeft: '1.25rem',
margin: 0,
})}
>
<li>
Have {studentName} open their device and go to <strong>Join Classroom</strong>
</li>
<li>They enter your classroom code to join</li>
<li>Once they appear in your classroom dashboard, you can observe their session</li>
</ol>
</div>
{/* Actions */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<Link
href={`/practice/${studentId}/dashboard`}
className={css({
display: 'block',
padding: '0.75rem 1rem',
fontSize: '1rem',
fontWeight: '500',
color: 'white',
backgroundColor: 'blue.500',
borderRadius: '8px',
textDecoration: 'none',
_hover: { backgroundColor: 'blue.600' },
})}
>
Go to {studentName}&apos;s Dashboard
</Link>
<Link
href="/practice"
className={css({
display: 'block',
padding: '0.75rem 1rem',
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
backgroundColor: 'transparent',
borderRadius: '8px',
textDecoration: 'none',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
})}
>
Back to Practice Home
</Link>
</div>
</div>
</main>
</PageWithNav>
)
}

View File

@@ -1,9 +1,10 @@
import { notFound, redirect } from 'next/navigation'
import { canPerformAction, isParentOf } from '@/lib/classroom'
import { getPlayerAccess, isParentOf } from '@/lib/classroom'
import { getActiveSessionPlan, getPlayer } from '@/lib/curriculum/server'
import type { ActiveSessionInfo } from '@/hooks/useClassroom'
import { getDbUserId } from '@/lib/viewer'
import { ObservationClient } from './ObservationClient'
import { StudentNotPresentPage } from './StudentNotPresentPage'
export const dynamic = 'force-dynamic'
@@ -23,11 +24,27 @@ export default async function PracticeObservationPage({ params }: ObservationPag
notFound()
}
const [canObserve, isParent] = await Promise.all([
canPerformAction(observerId, studentId, 'observe'),
const [access, isParent] = await Promise.all([
getPlayerAccess(observerId, studentId),
isParentOf(observerId, studentId),
])
// Check if user can observe (parent or teacher-present)
const canObserve = access.isParent || access.isPresent
if (!canObserve) {
// If they're a teacher but student isn't present, show helpful message
if (access.isTeacher && access.classroomId) {
return (
<StudentNotPresentPage
studentName={player.name}
studentEmoji={player.emoji}
studentId={studentId}
classroomId={access.classroomId}
/>
)
}
// Otherwise, they have no relationship to this student
notFound()
}

View File

@@ -3,16 +3,20 @@
import * as Dialog from '@radix-ui/react-dialog'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Z_INDEX } from '@/constants/zIndex'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { useTheme } from '@/contexts/ThemeContext'
import { useToast } from '@/components/common/ToastContext'
import type { ActiveSessionInfo } from '@/hooks/useClassroom'
import { useSessionObserver } from '@/hooks/useSessionObserver'
import { api } from '@/lib/queryClient'
import { css } from '../../../styled-system/css'
import { AbacusDock } from '../AbacusDock'
import { SessionShareButton } from './SessionShareButton'
import { LiveResultsPanel } from '../practice/LiveResultsPanel'
import { LiveSessionReportInline } from '../practice/LiveSessionReportModal'
import { ObserverTransitionView } from '../practice/ObserverTransitionView'
import { PracticeFeedback } from '../practice/PracticeFeedback'
import { PurposeBadge } from '../practice/PurposeBadge'
import { SessionProgressIndicator } from '../practice/SessionProgressIndicator'
@@ -35,6 +39,8 @@ interface SessionObserverModalProps {
observerId: string
/** Whether the observer can share this session (parents only) */
canShare?: boolean
/** Classroom ID for entry prompts (teachers only) */
classroomId?: string
}
interface SessionObserverViewProps {
@@ -47,6 +53,8 @@ interface SessionObserverViewProps {
isViewOnly?: boolean
/** Whether the observer can share this session (parents only) */
canShare?: boolean
/** Classroom ID for entry prompts (teachers only) */
classroomId?: string
onClose?: () => void
onRequestFullscreen?: () => void
renderCloseButton?: (button: ReactElement) => ReactElement
@@ -70,6 +78,7 @@ export function SessionObserverModal({
student,
observerId,
canShare,
classroomId,
}: SessionObserverModalProps) {
const router = useRouter()
@@ -118,6 +127,7 @@ export function SessionObserverModal({
student={student}
observerId={observerId}
canShare={canShare}
classroomId={classroomId}
onClose={onClose}
onRequestFullscreen={handleFullscreen}
renderCloseButton={(button) => <Dialog.Close asChild>{button}</Dialog.Close>}
@@ -136,6 +146,7 @@ export function SessionObserverView({
shareToken,
isViewOnly = false,
canShare = false,
classroomId,
onClose,
onRequestFullscreen,
renderCloseButton,
@@ -144,10 +155,20 @@ export function SessionObserverView({
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { requestDock, dock, setDockedValue, isDockedByUser } = useMyAbacus()
const { showSuccess, showError } = useToast()
// Subscribe to the session's socket channel
const { state, results, isConnected, isObserving, error, sendControl, sendPause, sendResume } =
useSessionObserver(session.sessionId, observerId, session.playerId, true, shareToken)
const {
state,
results,
transitionState,
isConnected,
isObserving,
error,
sendControl,
sendPause,
sendResume,
} = useSessionObserver(session.sessionId, observerId, session.playerId, true, shareToken)
// Track if we've paused the session (teacher controls resume)
const [hasPausedSession, setHasPausedSession] = useState(false)
@@ -155,6 +176,49 @@ export function SessionObserverView({
// Track if showing full report view (inline, not modal)
const [showFullReport, setShowFullReport] = useState(false)
// Track if entry prompt was sent (for authorization error case)
const [promptSent, setPromptSent] = useState(false)
// Mutation to send entry prompt to parents (for authorization error case)
const sendEntryPrompt = useMutation({
mutationFn: async () => {
if (!classroomId) throw new Error('No classroom ID')
const response = await api(`classrooms/${classroomId}/entry-prompts`, {
method: 'POST',
body: JSON.stringify({ playerIds: [session.playerId] }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to send prompt')
}
return response.json()
},
onSuccess: (data) => {
if (data.created > 0) {
setPromptSent(true)
showSuccess('Entry prompt sent', `${student.name}'s parent has been notified.`)
} else if (data.skipped?.length > 0) {
const reason = data.skipped[0]?.reason
if (reason === 'pending_prompt_exists') {
showError('Prompt already pending', `${student.name} already has a pending entry prompt.`)
} else if (reason === 'already_present') {
showSuccess('Already in classroom', `${student.name} is now in the classroom!`)
} else {
showError('Could not send prompt', reason || 'Unknown error')
}
}
},
onError: (err) => {
showError(
'Failed to send prompt',
err instanceof Error ? err.message : 'An unexpected error occurred'
)
},
})
// Check if this is an authorization error that might be due to student not being present
const isNotAuthorizedError = error === 'Not authorized to observe this session'
// Ref for measuring problem container height (same pattern as ActiveSession)
const problemRef = useRef<HTMLDivElement>(null)
const [problemHeight, setProblemHeight] = useState<number | null>(null)
@@ -425,7 +489,7 @@ export function SessionObserverView({
</div>
)}
{error && (
{error && !isNotAuthorizedError && (
<div
className={css({
textAlign: 'center',
@@ -439,7 +503,158 @@ export function SessionObserverView({
</div>
)}
{isObserving && !state && (
{/* Authorization error - show helpful UI for teachers to send entry prompt */}
{isNotAuthorizedError && (
<div
data-element="not-present-error"
className={css({
textAlign: 'center',
maxWidth: '400px',
width: '100%',
})}
>
<p
className={css({
fontSize: '1rem',
color: isDark ? 'gray.300' : 'gray.600',
marginBottom: '1.5rem',
lineHeight: '1.6',
})}
>
{student.name} is enrolled in your class, but you can only observe their practice
sessions when they are present in your classroom.
</p>
{/* Entry prompt section - only show for teachers with classroomId */}
{classroomId && !promptSent && (
<div
data-element="entry-prompt-section"
className={css({
backgroundColor: isDark ? 'orange.900/30' : 'orange.50',
border: '2px solid',
borderColor: isDark ? 'orange.700' : 'orange.300',
borderRadius: '12px',
padding: '1.25rem',
marginBottom: '1rem',
})}
>
<h3
className={css({
fontSize: '0.9375rem',
fontWeight: '600',
color: isDark ? 'orange.300' : 'orange.700',
marginBottom: '0.5rem',
})}
>
Notify {student.name}&apos;s parent
</h3>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.600',
marginBottom: '1rem',
lineHeight: '1.5',
})}
>
Send a notification asking them to enter the classroom.
</p>
<button
type="button"
onClick={() => sendEntryPrompt.mutate()}
disabled={sendEntryPrompt.isPending}
data-action="send-entry-prompt"
className={css({
width: '100%',
padding: '0.75rem 1rem',
fontSize: '1rem',
fontWeight: '600',
color: 'white',
backgroundColor: isDark ? 'orange.600' : 'orange.500',
borderRadius: '8px',
border: 'none',
cursor: sendEntryPrompt.isPending ? 'wait' : 'pointer',
opacity: sendEntryPrompt.isPending ? 0.7 : 1,
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'orange.500' : 'orange.600',
},
_disabled: {
cursor: 'wait',
opacity: 0.7,
},
})}
>
{sendEntryPrompt.isPending ? 'Sending...' : 'Send Entry Prompt'}
</button>
</div>
)}
{/* Prompt sent confirmation */}
{classroomId && promptSent && (
<div
data-element="prompt-sent-confirmation"
className={css({
backgroundColor: isDark ? 'green.900/30' : 'green.50',
border: '2px solid',
borderColor: isDark ? 'green.700' : 'green.300',
borderRadius: '12px',
padding: '1.25rem',
marginBottom: '1rem',
})}
>
<p
className={css({
fontSize: '0.9375rem',
fontWeight: '500',
color: isDark ? 'green.300' : 'green.700',
})}
>
Entry prompt sent to {student.name}&apos;s parent
</p>
</div>
)}
{/* Manual instructions (secondary) */}
<div
className={css({
backgroundColor: isDark ? 'gray.800' : 'gray.100',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
borderRadius: '12px',
padding: '1rem',
textAlign: 'left',
})}
>
<h3
className={css({
fontSize: '0.8125rem',
fontWeight: '600',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '0.5rem',
})}
>
Or have {student.name} join manually
</h3>
<ol
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.400' : 'gray.500',
lineHeight: '1.6',
paddingLeft: '1.25rem',
margin: 0,
})}
>
<li>
Have them open their device and go to <strong>Join Classroom</strong>
</li>
<li>They enter your classroom code to join</li>
<li>Once they appear in your dashboard, you can observe</li>
</ol>
</div>
</div>
)}
{isObserving && !state && !transitionState && (
<div
className={css({
textAlign: 'center',
@@ -455,8 +670,19 @@ export function SessionObserverView({
</div>
)}
{/* Part transition view - shows when student is between parts */}
{transitionState && (
<ObserverTransitionView
previousPartType={transitionState.previousPartType}
nextPartType={transitionState.nextPartType}
countdownStartTime={transitionState.countdownStartTime}
countdownDurationMs={transitionState.countdownDurationMs}
student={student}
/>
)}
{/* Main content - either problem view or full report view */}
{state && !showFullReport && (
{state && !showFullReport && !transitionState && (
<div
data-element="observer-main-content"
className={css({

View File

@@ -9,6 +9,7 @@ import type {
ProblemSlot,
SessionHealth,
SessionPart,
SessionPartType,
SessionPlan,
SlotResult,
} from '@/db/schema/session-plans'
@@ -16,6 +17,7 @@ import type {
import { css } from '../../../styled-system/css'
import { type AutoPauseStats, calculateAutoPauseInfo, type PauseInfo } from './autoPauseCalculator'
import { BrowseModeView, getLinearIndex } from './BrowseModeView'
import { PartTransitionScreen, TRANSITION_COUNTDOWN_MS } from './PartTransitionScreen'
import { SessionPausedModal } from './SessionPausedModal'
// Re-export types for consumers
@@ -144,6 +146,15 @@ interface ActiveSessionProps {
manualPauseRequest?: boolean
/** Called after manual pause has been handled (to clear the state) */
onManualPauseHandled?: () => void
/** Called when a part transition starts (for broadcasting to observers) */
onPartTransition?: (
previousPartType: SessionPartType | null,
nextPartType: SessionPartType,
countdownStartTime: number,
countdownDurationMs: number
) => void
/** Called when a part transition completes (for broadcasting to observers) */
onPartTransitionComplete?: () => void
}
/**
@@ -607,6 +618,8 @@ export function ActiveSession({
onTeacherResumeHandled,
manualPauseRequest,
onManualPauseHandled,
onPartTransition,
onPartTransitionComplete,
}: ActiveSessionProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@@ -952,6 +965,16 @@ export function ActiveSession({
// Track pause info for displaying in the modal (single source of truth)
const [pauseInfo, setPauseInfo] = useState<PauseInfo | undefined>(undefined)
// Part transition state - for showing transition screen between parts
const [isInPartTransition, setIsInPartTransition] = useState(false)
const [transitionData, setTransitionData] = useState<{
previousPartType: SessionPartType | null
nextPartType: SessionPartType
countdownStartTime: number
} | null>(null)
// Track the previous part index to detect part changes
const prevPartIndexRef = useRef<number>(plan.currentPartIndex)
// Browse mode state - isBrowseMode is controlled via props
// browseIndex can be controlled (browseIndexProp + onBrowseIndexChange) or internal
const [internalBrowseIndex, setInternalBrowseIndex] = useState(0)
@@ -1132,6 +1155,40 @@ export function ActiveSession({
}
}, [currentPartIndex, parts.length, onComplete])
// Detect part index changes and trigger transition screen
useEffect(() => {
const prevIndex = prevPartIndexRef.current
// If part index changed and we have a valid next part
if (currentPartIndex !== prevIndex && currentPartIndex < parts.length) {
const prevPart = prevIndex < parts.length ? parts[prevIndex] : null
const nextPart = parts[currentPartIndex]
// Trigger transition screen
const startTime = Date.now()
setTransitionData({
previousPartType: prevPart?.type ?? null,
nextPartType: nextPart.type,
countdownStartTime: startTime,
})
setIsInPartTransition(true)
// Broadcast transition to observers
onPartTransition?.(prevPart?.type ?? null, nextPart.type, startTime, TRANSITION_COUNTDOWN_MS)
}
// Update ref for next comparison
prevPartIndexRef.current = currentPartIndex
}, [currentPartIndex, parts, onPartTransition])
// Handle transition screen completion (countdown finished or user skipped)
const handleTransitionComplete = useCallback(() => {
setIsInPartTransition(false)
setTransitionData(null)
// Broadcast transition complete to observers
onPartTransitionComplete?.()
}, [onPartTransitionComplete])
// Initialize problem when slot changes and in loading phase
useEffect(() => {
if (currentPart && currentSlot && phase.phase === 'loading') {
@@ -1945,6 +2002,18 @@ export function ActiveSession({
onResume={handleResume}
onEndSession={() => onEndEarly('Session ended by user')}
/>
{/* Part Transition Screen - full screen overlay between parts */}
{transitionData && (
<PartTransitionScreen
isVisible={isInPartTransition}
previousPartType={transitionData.previousPartType}
nextPartType={transitionData.nextPartType}
countdownStartTime={transitionData.countdownStartTime}
student={student}
onComplete={handleTransitionComplete}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,295 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { useEntryPrompts, type EntryPrompt } from '@/hooks/useEntryPrompts'
import { css } from '../../../styled-system/css'
/**
* Banner that shows pending entry prompts from teachers to parents
*
* Displayed at the top of the practice page when a teacher has requested
* that a parent's child enter their classroom.
*/
export function EntryPromptBanner() {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const {
prompts,
isLoading,
acceptPrompt,
declinePrompt,
isAccepting,
isDeclining,
acceptingPromptId,
decliningPromptId,
} = useEntryPrompts()
const handleAccept = useCallback(
async (promptId: string) => {
try {
await acceptPrompt(promptId)
} catch (error) {
console.error('Failed to accept prompt:', error)
}
},
[acceptPrompt]
)
const handleDecline = useCallback(
async (promptId: string) => {
try {
await declinePrompt(promptId)
} catch (error) {
console.error('Failed to decline prompt:', error)
}
},
[declinePrompt]
)
// Don't render if loading or no prompts
if (isLoading || prompts.length === 0) {
return null
}
return (
<div
data-component="entry-prompt-banner"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '12px',
marginBottom: '16px',
})}
>
{prompts.map((prompt) => (
<PromptCard
key={prompt.id}
prompt={prompt}
onAccept={handleAccept}
onDecline={handleDecline}
isAccepting={isAccepting && acceptingPromptId === prompt.id}
isDeclining={isDeclining && decliningPromptId === prompt.id}
isDark={isDark}
/>
))}
</div>
)
}
interface PromptCardProps {
prompt: EntryPrompt
onAccept: (promptId: string) => void
onDecline: (promptId: string) => void
isAccepting: boolean
isDeclining: boolean
isDark: boolean
}
function PromptCard({
prompt,
onAccept,
onDecline,
isAccepting,
isDeclining,
isDark,
}: PromptCardProps) {
const [timeLeft, setTimeLeft] = useState(() => getTimeRemaining(prompt.expiresAt))
const isExpired = timeLeft <= 0
const isLoading = isAccepting || isDeclining
// Update countdown every second
useEffect(() => {
const interval = setInterval(() => {
const remaining = getTimeRemaining(prompt.expiresAt)
setTimeLeft(remaining)
}, 1000)
return () => clearInterval(interval)
}, [prompt.expiresAt])
// Don't render if expired
if (isExpired) {
return null
}
return (
<div
data-element="prompt-card"
data-prompt-id={prompt.id}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '16px',
padding: '16px',
bg: isDark ? 'orange.900/30' : 'orange.50',
border: '2px solid',
borderColor: isDark ? 'orange.700' : 'orange.300',
borderRadius: '12px',
flexWrap: 'wrap',
})}
>
{/* Prompt message */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
flex: '1',
minWidth: '200px',
})}
>
<span
className={css({
fontSize: '1.5rem',
})}
>
{prompt.player.emoji}
</span>
<div>
<p
className={css({
fontSize: '0.9375rem',
fontWeight: 'medium',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
<strong>{prompt.teacher.displayName}</strong> wants{' '}
<strong>{prompt.player.name}</strong> to enter <strong>{prompt.classroom.name}</strong>
</p>
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.400' : 'gray.500',
marginTop: '2px',
})}
>
Classroom invitation
</p>
</div>
</div>
{/* Countdown and actions */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
})}
>
{/* Countdown */}
<CountdownTimer timeLeft={timeLeft} isDark={isDark} />
{/* Actions */}
<div
className={css({
display: 'flex',
gap: '8px',
})}
>
<button
type="button"
onClick={() => onDecline(prompt.id)}
disabled={isLoading}
data-action="decline-prompt"
className={css({
padding: '8px 16px',
bg: 'transparent',
color: isDark ? 'gray.300' : 'gray.600',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: '8px',
fontSize: '0.875rem',
fontWeight: 'medium',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
transition: 'all 0.15s ease',
_hover: {
bg: isDark ? 'gray.800' : 'gray.100',
},
_disabled: {
cursor: 'not-allowed',
opacity: 0.5,
},
})}
>
{isDeclining ? 'Declining...' : 'Decline'}
</button>
<button
type="button"
onClick={() => onAccept(prompt.id)}
disabled={isLoading}
data-action="accept-prompt"
className={css({
padding: '8px 16px',
bg: isDark ? 'green.700' : 'green.500',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '0.875rem',
fontWeight: 'medium',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
transition: 'all 0.15s ease',
_hover: {
bg: isDark ? 'green.600' : 'green.600',
},
_disabled: {
cursor: 'not-allowed',
opacity: 0.5,
},
})}
>
{isAccepting ? 'Entering...' : 'Accept'}
</button>
</div>
</div>
</div>
)
}
interface CountdownTimerProps {
timeLeft: number
isDark: boolean
}
function CountdownTimer({ timeLeft, isDark }: CountdownTimerProps) {
const minutes = Math.floor(timeLeft / 60)
const seconds = timeLeft % 60
const isUrgent = timeLeft < 60 // Less than 1 minute
return (
<div
data-element="countdown-timer"
className={css({
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 10px',
bg: isUrgent ? (isDark ? 'red.900/50' : 'red.100') : isDark ? 'gray.800' : 'gray.100',
borderRadius: '16px',
fontSize: '0.8125rem',
fontWeight: 'medium',
fontFamily: 'monospace',
color: isUrgent ? (isDark ? 'red.400' : 'red.600') : isDark ? 'gray.400' : 'gray.600',
})}
>
<span>Expires</span>
<span>
{minutes}:{seconds.toString().padStart(2, '0')}
</span>
</div>
)
}
/**
* Get time remaining in seconds until expiry
*/
function getTimeRemaining(expiresAt: string): number {
const expiry = new Date(expiresAt).getTime()
const now = Date.now()
const diff = Math.floor((expiry - now) / 1000)
return Math.max(0, diff)
}

View File

@@ -559,6 +559,16 @@ export function NotesModal({
</DropdownMenu.Item>
)}
{actions.promptToEnter && (
<DropdownMenu.Item
className={menuItemStyle(isDark)}
onSelect={handlers.promptToEnter}
>
<span>{ACTION_DEFINITIONS.promptToEnter.icon}</span>
<span>{ACTION_DEFINITIONS.promptToEnter.label}</span>
</DropdownMenu.Item>
)}
<DropdownMenu.Separator className={separatorStyle(isDark)} />
{/* Management actions */}

View File

@@ -0,0 +1,262 @@
'use client'
/**
* ObserverTransitionView - Simplified transition view for observers
*
* Shows "Student transitioning..." with the same synchronized countdown
* as the student's PartTransitionScreen.
*/
import { useEffect, useRef, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import type { SessionPartType } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
// ============================================================================
// Types
// ============================================================================
export interface ObserverTransitionViewProps {
/** Part type transitioning FROM (null if session start) */
previousPartType: SessionPartType | null
/** Part type transitioning TO */
nextPartType: SessionPartType
/** Timestamp when countdown started (for sync) */
countdownStartTime: number
/** Countdown duration in ms */
countdownDurationMs: number
/** Student info for display */
student: {
name: string
emoji: string
color: string
}
}
// ============================================================================
// Helper Functions
// ============================================================================
function getPartTypeEmoji(type: SessionPartType): string {
switch (type) {
case 'abacus':
return '🧮'
case 'visualization':
return '🧠'
case 'linear':
return '✏️'
}
}
function getPartTypeLabel(type: SessionPartType): string {
switch (type) {
case 'abacus':
return 'Abacus'
case 'visualization':
return 'Visualization'
case 'linear':
return 'Equations'
}
}
// ============================================================================
// Component
// ============================================================================
export function ObserverTransitionView({
previousPartType,
nextPartType,
countdownStartTime,
countdownDurationMs,
student,
}: ObserverTransitionViewProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Track elapsed time for countdown
const [elapsedMs, setElapsedMs] = useState(0)
const animationFrameRef = useRef<number | null>(null)
// Countdown timer using requestAnimationFrame for smooth updates
useEffect(() => {
const updateCountdown = () => {
const now = Date.now()
const elapsed = now - countdownStartTime
setElapsedMs(elapsed)
if (elapsed < countdownDurationMs) {
animationFrameRef.current = requestAnimationFrame(updateCountdown)
}
}
animationFrameRef.current = requestAnimationFrame(updateCountdown)
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [countdownStartTime, countdownDurationMs])
// Calculate countdown values
const remainingMs = Math.max(0, countdownDurationMs - elapsedMs)
const remainingSeconds = Math.ceil(remainingMs / 1000)
const percentRemaining = (remainingMs / countdownDurationMs) * 100
// SVG parameters for countdown circle
const viewBoxSize = 100
const center = viewBoxSize / 2
const radius = 42
const circumference = 2 * Math.PI * radius
const strokeDashoffset = circumference * (1 - percentRemaining / 100)
// Color based on time remaining
const countdownColor =
percentRemaining > 50 ? (isDark ? '#22c55e' : '#16a34a') : isDark ? '#eab308' : '#ca8a04'
return (
<div
data-component="observer-transition-view"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5rem',
padding: '2rem',
textAlign: 'center',
})}
>
{/* Student avatar */}
<div
data-element="student-avatar"
className={css({
width: '56px',
height: '56px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2rem',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
})}
style={{ backgroundColor: student.color }}
>
{student.emoji}
</div>
{/* Message */}
<div>
<h3
className={css({
fontSize: '1.25rem',
fontWeight: '600',
color: isDark ? 'gray.200' : 'gray.700',
marginBottom: '0.5rem',
})}
>
{student.name} is transitioning...
</h3>
<p
className={css({
fontSize: '0.9375rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Preparing for the next section
</p>
</div>
{/* Part type indicator */}
<div
data-element="part-indicator"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 1rem',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderRadius: '8px',
fontSize: '0.875rem',
})}
>
{previousPartType && (
<>
<span className={css({ opacity: 0.5 })}>{getPartTypeEmoji(previousPartType)}</span>
<span className={css({ color: isDark ? 'gray.500' : 'gray.400' })}></span>
</>
)}
<span>{getPartTypeEmoji(nextPartType)}</span>
<span
className={css({
fontWeight: '500',
color: isDark ? 'gray.300' : 'gray.600',
})}
>
{getPartTypeLabel(nextPartType)}
</span>
</div>
{/* Countdown */}
<div
data-element="countdown"
className={css({
width: '64px',
height: '64px',
position: 'relative',
})}
>
<svg
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
className={css({
width: '100%',
height: '100%',
transform: 'rotate(-90deg)',
})}
>
{/* Background circle */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}
strokeWidth="6"
/>
{/* Progress arc */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={countdownColor}
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className={css({
transition: 'stroke-dashoffset 0.1s linear, stroke 0.3s ease',
})}
/>
</svg>
{/* Seconds in center */}
<div
className={css({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '1.25rem',
fontWeight: 'bold',
fontFamily: 'var(--font-mono, monospace)',
color: countdownColor,
})}
>
{remainingSeconds}
</div>
</div>
</div>
)
}
export default ObserverTransitionView

View File

@@ -0,0 +1,438 @@
'use client'
/**
* PartTransitionScreen - Full-screen transition between practice session parts
*
* Shows a kid-friendly message about the upcoming part type change,
* especially important for telling kids to put away their abacus
* when transitioning from abacus to visualization mode.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import type { SessionPartType } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import {
selectTransitionMessage,
requiresAbacusPutAway,
requiresAbacusPickUp,
} from './partTransitionMessages'
// ============================================================================
// Constants
// ============================================================================
/** Default countdown duration in milliseconds */
export const TRANSITION_COUNTDOWN_MS = 7000
/** Update interval for countdown display */
const COUNTDOWN_UPDATE_INTERVAL_MS = 100
// ============================================================================
// Types
// ============================================================================
export interface PartTransitionScreenProps {
/** Whether the transition screen is visible */
isVisible: boolean
/** The part type we're transitioning FROM (null if session start) */
previousPartType: SessionPartType | null
/** The part type we're transitioning TO */
nextPartType: SessionPartType
/** Countdown duration in ms */
countdownMs?: number
/** Timestamp when countdown started (for sync) */
countdownStartTime: number
/** Student info for display */
student: {
name: string
emoji: string
color: string
}
/** Called when transition completes (countdown or skip) */
onComplete: () => void
/** Optional seed for message selection (e.g., session ID hash) */
messageSeed?: number
}
// ============================================================================
// Helper Functions
// ============================================================================
function getPartTypeEmoji(type: SessionPartType): string {
switch (type) {
case 'abacus':
return '🧮'
case 'visualization':
return '🧠'
case 'linear':
return '✏️'
}
}
function getPartTypeLabel(type: SessionPartType): string {
switch (type) {
case 'abacus':
return 'Abacus'
case 'visualization':
return 'Visualization'
case 'linear':
return 'Equations'
}
}
// ============================================================================
// Component
// ============================================================================
export function PartTransitionScreen({
isVisible,
previousPartType,
nextPartType,
countdownMs = TRANSITION_COUNTDOWN_MS,
countdownStartTime,
student,
onComplete,
messageSeed,
}: PartTransitionScreenProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Track elapsed time for countdown
const [elapsedMs, setElapsedMs] = useState(0)
const animationFrameRef = useRef<number | null>(null)
const hasCompletedRef = useRef(false)
// Select message once when transition starts
const message = useMemo(() => {
return selectTransitionMessage(previousPartType, nextPartType, messageSeed)
}, [previousPartType, nextPartType, messageSeed])
// Check if abacus action is needed
const showAbacusPutAway = requiresAbacusPutAway(previousPartType, nextPartType)
const showAbacusPickUp = requiresAbacusPickUp(previousPartType, nextPartType)
// Handle skip
const handleSkip = useCallback(() => {
if (!hasCompletedRef.current) {
hasCompletedRef.current = true
onComplete()
}
}, [onComplete])
// Countdown timer using requestAnimationFrame for smooth updates
useEffect(() => {
if (!isVisible) {
hasCompletedRef.current = false
setElapsedMs(0)
return
}
const updateCountdown = () => {
const now = Date.now()
const elapsed = now - countdownStartTime
setElapsedMs(elapsed)
if (elapsed >= countdownMs) {
// Countdown complete
if (!hasCompletedRef.current) {
hasCompletedRef.current = true
onComplete()
}
} else {
// Continue updating
animationFrameRef.current = requestAnimationFrame(updateCountdown)
}
}
animationFrameRef.current = requestAnimationFrame(updateCountdown)
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [isVisible, countdownStartTime, countdownMs, onComplete])
if (!isVisible) return null
// Calculate countdown values
const remainingMs = Math.max(0, countdownMs - elapsedMs)
const remainingSeconds = Math.ceil(remainingMs / 1000)
const percentRemaining = (remainingMs / countdownMs) * 100
// SVG parameters for countdown circle
const viewBoxSize = 100
const center = viewBoxSize / 2
const radius = 42
const circumference = 2 * Math.PI * radius
const strokeDashoffset = circumference * (1 - percentRemaining / 100)
// Color based on time remaining
const countdownColor =
percentRemaining > 50 ? (isDark ? '#22c55e' : '#16a34a') : isDark ? '#eab308' : '#ca8a04'
return (
<div
data-component="part-transition-screen"
data-previous-part={previousPartType ?? 'start'}
data-next-part={nextPartType}
className={css({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.9)' : 'rgba(0, 0, 0, 0.75)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1.5rem',
})}
>
{/* Main content card */}
<div
data-element="transition-content"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1.5rem',
padding: '2rem',
maxWidth: '420px',
width: '100%',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '24px',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.4)',
})}
>
{/* Student avatar */}
<div
data-element="student-avatar"
className={css({
width: '64px',
height: '64px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2.25rem',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
})}
style={{ backgroundColor: student.color }}
>
{student.emoji}
</div>
{/* Message */}
<div
data-element="transition-message"
className={css({
textAlign: 'center',
})}
>
<h2
className={css({
fontSize: '1.75rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.800',
marginBottom: '0.5rem',
})}
>
{message.headline}
</h2>
{message.subtitle && (
<p
className={css({
fontSize: '1.125rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
{message.subtitle}
</p>
)}
</div>
{/* Part type indicator */}
<div
data-element="part-indicator"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem 1.25rem',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderRadius: '12px',
})}
>
{previousPartType && (
<>
<span
className={css({
fontSize: '1.5rem',
opacity: 0.5,
})}
>
{getPartTypeEmoji(previousPartType)}
</span>
<span
className={css({
color: isDark ? 'gray.500' : 'gray.400',
fontSize: '1.25rem',
})}
>
</span>
</>
)}
<span
className={css({
fontSize: '1.5rem',
})}
>
{getPartTypeEmoji(nextPartType)}
</span>
<span
className={css({
fontSize: '1rem',
fontWeight: '600',
color: isDark ? 'gray.200' : 'gray.700',
})}
>
{getPartTypeLabel(nextPartType)}
</span>
</div>
{/* Abacus action reminder */}
{(showAbacusPutAway || showAbacusPickUp) && (
<div
data-element="abacus-action"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.625rem 1rem',
backgroundColor: showAbacusPutAway
? isDark
? 'amber.900/50'
: 'amber.100'
: isDark
? 'green.900/50'
: 'green.100',
borderRadius: '8px',
fontSize: '0.9375rem',
color: showAbacusPutAway
? isDark
? 'amber.200'
: 'amber.800'
: isDark
? 'green.200'
: 'green.800',
})}
>
<span>{showAbacusPutAway ? '📦' : '🧮'}</span>
<span>{showAbacusPutAway ? 'Put your abacus aside' : 'Get your abacus ready'}</span>
</div>
)}
{/* Countdown timer */}
<div
data-element="countdown"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.75rem',
})}
>
{/* Circular countdown */}
<div
className={css({
width: '80px',
height: '80px',
position: 'relative',
})}
>
<svg
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
className={css({
width: '100%',
height: '100%',
transform: 'rotate(-90deg)',
})}
>
{/* Background circle */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}
strokeWidth="8"
/>
{/* Progress arc */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={countdownColor}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className={css({
transition: 'stroke-dashoffset 0.1s linear, stroke 0.3s ease',
})}
/>
</svg>
{/* Seconds in center */}
<div
className={css({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '1.5rem',
fontWeight: 'bold',
fontFamily: 'var(--font-mono, monospace)',
color: countdownColor,
})}
>
{remainingSeconds}
</div>
</div>
{/* Skip button */}
<button
data-action="skip-transition"
onClick={handleSkip}
className={css({
padding: '0.625rem 1.5rem',
fontSize: '1rem',
fontWeight: '600',
color: isDark ? 'gray.300' : 'gray.600',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.300',
},
_active: {
transform: 'scale(0.98)',
},
})}
>
Continue
</button>
</div>
</div>
</div>
)
}
export default PartTransitionScreen

View File

@@ -74,6 +74,8 @@ export interface SessionHudData {
onToggleBrowse: () => void
/** Navigate to specific problem in browse mode */
onBrowseNavigate?: (linearIndex: number) => void
/** Whether the end session request is in flight */
isEndingSession?: boolean
}
interface PracticeSubNavProps {
@@ -851,20 +853,38 @@ export function PracticeSubNav({
padding: '0.5rem 0.75rem',
borderRadius: '4px',
fontSize: '0.875rem',
cursor: 'pointer',
cursor: sessionHud.isEndingSession ? 'wait' : 'pointer',
outline: 'none',
color: isDark ? 'red.400' : 'red.600',
opacity: sessionHud.isEndingSession ? 0.6 : 1,
_hover: {
backgroundColor: isDark ? 'red.900/50' : 'red.50',
backgroundColor: sessionHud.isEndingSession
? 'transparent'
: isDark
? 'red.900/50'
: 'red.50',
},
_focus: {
backgroundColor: isDark ? 'red.900/50' : 'red.50',
backgroundColor: sessionHud.isEndingSession
? 'transparent'
: isDark
? 'red.900/50'
: 'red.50',
},
})}
onSelect={sessionHud.onEndEarly}
onSelect={(e) => {
if (sessionHud.isEndingSession) {
e.preventDefault() // Keep menu open while loading
return
}
// Prevent menu from closing - we want to show the loading state
e.preventDefault()
sessionHud.onEndEarly()
}}
disabled={sessionHud.isEndingSession}
>
<span></span>
<span>End Session</span>
<span>{sessionHud.isEndingSession ? '⏳' : '⏹'}</span>
<span>{sessionHud.isEndingSession ? 'Ending...' : 'End Session'}</span>
</DropdownMenu.Item>
{/* Observe session - for parents/teachers to open observation page */}

View File

@@ -1,10 +1,13 @@
'use client'
import * as Popover from '@radix-ui/react-popover'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ShareCodePanel } from '@/components/common'
import { Z_INDEX } from '@/constants/zIndex'
import { useTheme } from '@/contexts/ThemeContext'
import { useUpdateClassroom } from '@/hooks/useClassroom'
import { useShareCode } from '@/hooks/useShareCode'
import type { Classroom } from '@/db/schema'
import {
formatSkillChipName,
getSkillDisplayName,
@@ -23,8 +26,8 @@ interface StudentFilterBarProps {
availableViews?: StudentView[]
/** Counts per view */
viewCounts?: Partial<Record<StudentView, number>>
/** Classroom code for teachers to share */
classroomCode?: string
/** Classroom data for teachers (includes code and settings) */
classroom?: Classroom | null
/** Current search query */
searchQuery: string
/** Callback when search query changes */
@@ -45,6 +48,10 @@ interface StudentFilterBarProps {
selectedCount?: number
/** Callback when bulk archive is clicked */
onBulkArchive?: () => void
/** Callback when bulk prompt to enter is clicked */
onBulkPromptToEnter?: () => void
/** Number of students eligible for entry prompt (enrolled but not present) */
promptEligibleCount?: number
/** Callback to clear selection */
onClearSelection?: () => void
}
@@ -64,7 +71,7 @@ export function StudentFilterBar({
onViewChange,
availableViews,
viewCounts,
classroomCode,
classroom,
searchQuery,
onSearchChange,
skillFilters,
@@ -75,6 +82,8 @@ export function StudentFilterBar({
onAddStudent,
selectedCount = 0,
onBulkArchive,
onBulkPromptToEnter,
promptEligibleCount = 0,
onClearSelection,
}: StudentFilterBarProps) {
const { resolvedTheme } = useTheme()
@@ -181,8 +190,8 @@ export function StudentFilterBar({
viewCounts={viewCounts}
/>
{/* Classroom code - teachers only */}
{classroomCode && <ClassroomShareChip code={classroomCode} />}
{/* Classroom code and settings - teachers only */}
{classroom && <ClassroomChipWithSettings classroom={classroom} />}
</div>
)}
@@ -242,6 +251,28 @@ export function StudentFilterBar({
Archive
</button>
)}
{onBulkPromptToEnter && promptEligibleCount > 0 && (
<button
type="button"
onClick={onBulkPromptToEnter}
data-action="bulk-prompt-to-enter"
className={css({
padding: '6px 12px',
bg: isDark ? 'orange.700' : 'orange.500',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '13px',
fontWeight: 'medium',
cursor: 'pointer',
_hover: {
bg: isDark ? 'orange.600' : 'orange.600',
},
})}
>
Prompt to Enter ({promptEligibleCount})
</button>
)}
{onClearSelection && (
<button
type="button"
@@ -598,9 +629,293 @@ export function StudentFilterBar({
}
/**
* Compact share chip for classroom code with QR popover
* Preset options for entry prompt expiry
*/
function ClassroomShareChip({ code }: { code: string }) {
const shareCode = useShareCode({ type: 'classroom', code })
return <ShareCodePanel shareCode={shareCode} compact showRegenerate={false} />
const EXPIRY_OPTIONS = [
{ value: null, label: 'Default (30 min)' },
{ value: 15, label: '15 minutes' },
{ value: 30, label: '30 minutes' },
{ value: 45, label: '45 minutes' },
{ value: 60, label: '1 hour' },
{ value: 90, label: '1.5 hours' },
{ value: 120, label: '2 hours' },
] as const
/**
* Classroom share chip with settings popover
*/
function ClassroomChipWithSettings({ classroom }: { classroom: Classroom }) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const shareCode = useShareCode({ type: 'classroom', code: classroom.code })
const updateClassroom = useUpdateClassroom()
const [isOpen, setIsOpen] = useState(false)
const [editingName, setEditingName] = useState(false)
const [nameValue, setNameValue] = useState(classroom.name)
const nameInputRef = useRef<HTMLInputElement>(null)
// Reset name value when classroom changes or popover opens
useEffect(() => {
setNameValue(classroom.name)
setEditingName(false)
}, [classroom.name, isOpen])
// Focus input when editing starts
useEffect(() => {
if (editingName && nameInputRef.current) {
nameInputRef.current.focus()
nameInputRef.current.select()
}
}, [editingName])
const handleNameSave = useCallback(() => {
const trimmedName = nameValue.trim()
if (trimmedName && trimmedName !== classroom.name) {
updateClassroom.mutate({
classroomId: classroom.id,
name: trimmedName,
})
}
setEditingName(false)
}, [classroom.id, classroom.name, nameValue, updateClassroom])
const handleExpiryChange = useCallback(
(value: number | null) => {
updateClassroom.mutate({
classroomId: classroom.id,
entryPromptExpiryMinutes: value,
})
},
[classroom.id, updateClassroom]
)
const currentExpiry = classroom.entryPromptExpiryMinutes
return (
<div
data-element="classroom-chip-with-settings"
className={css({
display: 'flex',
alignItems: 'center',
gap: '4px',
})}
>
<ShareCodePanel shareCode={shareCode} compact showRegenerate={false} />
<Popover.Root open={isOpen} onOpenChange={setIsOpen}>
<Popover.Trigger asChild>
<button
type="button"
data-action="classroom-settings"
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: '14px',
cursor: 'pointer',
transition: 'all 0.15s ease',
flexShrink: 0,
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderColor: isDark ? 'gray.600' : 'gray.400',
},
})}
aria-label="Classroom settings"
>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
data-component="classroom-settings-popover"
side="bottom"
align="end"
sideOffset={8}
className={css({
width: '240px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
boxShadow: 'lg',
padding: '12px',
zIndex: Z_INDEX.POPOVER,
animation: 'fadeIn 0.15s ease',
})}
>
<h3
className={css({
fontSize: '13px',
fontWeight: '600',
color: isDark ? 'gray.200' : 'gray.700',
marginBottom: '12px',
})}
>
Classroom Settings
</h3>
{/* Classroom name */}
<div data-setting="classroom-name" className={css({ marginBottom: '12px' })}>
<label
className={css({
display: 'block',
fontSize: '12px',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '4px',
})}
>
Classroom Name
</label>
{editingName ? (
<div className={css({ display: 'flex', gap: '4px' })}>
<input
ref={nameInputRef}
type="text"
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleNameSave()
} else if (e.key === 'Escape') {
setNameValue(classroom.name)
setEditingName(false)
}
}}
onBlur={handleNameSave}
disabled={updateClassroom.isPending}
className={css({
flex: 1,
padding: '6px 8px',
fontSize: '13px',
borderRadius: '6px',
border: '1px solid',
borderColor: 'blue.500',
backgroundColor: isDark ? 'gray.700' : 'white',
color: isDark ? 'gray.100' : 'gray.800',
outline: '2px solid',
outlineColor: 'blue.500',
outlineOffset: '1px',
})}
/>
</div>
) : (
<button
type="button"
onClick={() => setEditingName(true)}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '6px 8px',
fontSize: '13px',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
backgroundColor: isDark ? 'gray.700' : 'white',
color: isDark ? 'gray.100' : 'gray.800',
cursor: 'pointer',
textAlign: 'left',
_hover: {
borderColor: isDark ? 'gray.500' : 'gray.400',
backgroundColor: isDark ? 'gray.600' : 'gray.50',
},
})}
>
<span
className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})}
>
{classroom.name}
</span>
<span
className={css({
fontSize: '11px',
color: isDark ? 'gray.400' : 'gray.400',
flexShrink: 0,
marginLeft: '8px',
})}
>
</span>
</button>
)}
</div>
{/* Entry prompt expiry setting */}
<div data-setting="entry-prompt-expiry">
<label
className={css({
display: 'block',
fontSize: '12px',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '4px',
})}
>
Entry prompt expires after
</label>
<select
value={currentExpiry ?? ''}
onChange={(e) => {
const val = e.target.value
handleExpiryChange(val === '' ? null : Number(val))
}}
disabled={updateClassroom.isPending}
className={css({
width: '100%',
padding: '6px 8px',
fontSize: '13px',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
backgroundColor: isDark ? 'gray.700' : 'white',
color: isDark ? 'gray.100' : 'gray.800',
cursor: updateClassroom.isPending ? 'wait' : 'pointer',
opacity: updateClassroom.isPending ? 0.7 : 1,
_focus: {
outline: '2px solid',
outlineColor: 'blue.500',
outlineOffset: '1px',
},
})}
>
{EXPIRY_OPTIONS.map((opt) => (
<option key={opt.value ?? 'default'} value={opt.value ?? ''}>
{opt.label}
</option>
))}
</select>
<p
className={css({
fontSize: '11px',
color: isDark ? 'gray.500' : 'gray.400',
marginTop: '4px',
lineHeight: '1.4',
})}
>
How long parents have to respond before the entry prompt expires
</p>
</div>
<Popover.Arrow
className={css({
fill: isDark ? 'gray.800' : 'white',
})}
/>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
)
}

View File

@@ -0,0 +1,312 @@
/**
* Unit tests for student action visibility rules
*
* Tests the getAvailableActions function which determines
* which actions are available for a student based on context.
*/
import { describe, expect, it } from 'vitest'
import {
getAvailableActions,
type StudentActionContext,
type StudentActionData,
} from '../studentActions'
describe('studentActions', () => {
describe('getAvailableActions', () => {
const teacherContext: StudentActionContext = {
isTeacher: true,
classroomId: 'classroom-1',
}
const parentContext: StudentActionContext = {
isTeacher: false,
classroomId: undefined,
}
describe('promptToEnter action', () => {
it('teacher can prompt enrolled non-present student', () => {
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
relationship: {
isMyChild: false,
isEnrolled: true,
isPresent: false,
enrollmentStatus: 'enrolled',
},
}
const actions = getAvailableActions(student, teacherContext)
expect(actions.promptToEnter).toBe(true)
})
it('teacher cannot prompt student already present', () => {
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
relationship: {
isMyChild: false,
isEnrolled: true,
isPresent: true,
enrollmentStatus: 'enrolled',
},
}
const actions = getAvailableActions(student, teacherContext)
expect(actions.promptToEnter).toBe(false)
})
it('teacher cannot prompt non-enrolled student', () => {
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
relationship: {
isMyChild: true,
isEnrolled: false,
isPresent: false,
enrollmentStatus: null,
},
}
const actions = getAvailableActions(student, teacherContext)
expect(actions.promptToEnter).toBe(false)
})
it('parent cannot prompt to enter (only teachers)', () => {
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
relationship: {
isMyChild: true,
isEnrolled: true,
isPresent: false,
enrollmentStatus: 'enrolled',
},
}
const actions = getAvailableActions(student, parentContext)
expect(actions.promptToEnter).toBe(false)
})
})
describe('watchSession action', () => {
it('shows watchSession when student is practicing with sessionId', () => {
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
activity: {
status: 'practicing',
sessionId: 'session-123',
},
}
const actions = getAvailableActions(student, teacherContext)
expect(actions.watchSession).toBe(true)
})
it('hides watchSession when student is idle', () => {
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
activity: {
status: 'idle',
},
}
const actions = getAvailableActions(student, teacherContext)
expect(actions.watchSession).toBe(false)
})
it('hides watchSession when practicing but no sessionId', () => {
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
activity: {
status: 'practicing',
// no sessionId
},
}
const actions = getAvailableActions(student, teacherContext)
expect(actions.watchSession).toBe(false)
})
it('hides watchSession when no activity data', () => {
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
}
const actions = getAvailableActions(student, teacherContext)
expect(actions.watchSession).toBe(false)
})
it('watchSession available even when student not present (for entry prompt flow)', () => {
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
relationship: {
isMyChild: false,
isEnrolled: true,
isPresent: false,
enrollmentStatus: 'enrolled',
},
activity: {
status: 'practicing',
sessionId: 'session-123',
},
}
const actions = getAvailableActions(student, teacherContext)
// watchSession should be true - teacher sees student is practicing
// When they click it, they'll get the entry prompt UI if student isn't present
expect(actions.watchSession).toBe(true)
})
})
describe('enterClassroom action', () => {
it('parent can enter their enrolled child into classroom', () => {
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
relationship: {
isMyChild: true,
isEnrolled: true,
isPresent: false,
enrollmentStatus: 'enrolled',
},
}
const actions = getAvailableActions(student, parentContext, {
hasEnrolledClassrooms: true,
})
expect(actions.enterClassroom).toBe(true)
})
it('parent cannot enter child already present', () => {
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
relationship: {
isMyChild: true,
isEnrolled: true,
isPresent: true,
enrollmentStatus: 'enrolled',
},
}
const actions = getAvailableActions(student, parentContext, {
hasEnrolledClassrooms: true,
})
expect(actions.enterClassroom).toBe(false)
})
it('teacher-parent can still enter their own child', () => {
const teacherParentContext: StudentActionContext = {
isTeacher: true,
classroomId: 'classroom-1',
}
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
relationship: {
isMyChild: true,
isEnrolled: true,
isPresent: false,
enrollmentStatus: 'enrolled',
},
}
const actions = getAvailableActions(student, teacherParentContext, {
hasEnrolledClassrooms: true,
})
expect(actions.enterClassroom).toBe(true)
})
})
describe('pending enrollment status', () => {
it('disables all actions for pending enrollment', () => {
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
relationship: {
isMyChild: false,
isEnrolled: false,
isPresent: false,
enrollmentStatus: 'pending_teacher',
},
}
const actions = getAvailableActions(student, teacherContext)
expect(actions.promptToEnter).toBe(false)
expect(actions.watchSession).toBe(false)
expect(actions.enterClassroom).toBe(false)
expect(actions.leaveClassroom).toBe(false)
expect(actions.removeFromClassroom).toBe(false)
expect(actions.enrollInClassroom).toBe(false)
expect(actions.unenrollStudent).toBe(false)
})
})
describe('combined scenarios', () => {
it('enrolled practicing student shows both watchSession and cannot be prompted', () => {
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
relationship: {
isMyChild: false,
isEnrolled: true,
isPresent: true,
enrollmentStatus: 'enrolled',
},
activity: {
status: 'practicing',
sessionId: 'session-123',
},
}
const actions = getAvailableActions(student, teacherContext)
expect(actions.watchSession).toBe(true)
expect(actions.promptToEnter).toBe(false) // already present
})
it('enrolled non-present practicing student can be watched and prompted', () => {
const student: StudentActionData = {
id: 'student-1',
name: 'Test Student',
relationship: {
isMyChild: false,
isEnrolled: true,
isPresent: false,
enrollmentStatus: 'enrolled',
},
activity: {
status: 'practicing',
sessionId: 'session-123',
},
}
const actions = getAvailableActions(student, teacherContext)
expect(actions.watchSession).toBe(true)
expect(actions.promptToEnter).toBe(true)
})
})
})
})

View File

@@ -44,6 +44,7 @@ export {
ProjectingBanner,
} from './BannerSlots'
export { CompactBanner } from './CompactBanner'
export { EntryPromptBanner } from './EntryPromptBanner'
export type { ActiveSessionState } from './ActiveSessionBanner'
export type { CurrentPhaseInfo, SkillHealthSummary } from './ProgressDashboard'
export { ProgressDashboard } from './ProgressDashboard'
@@ -76,3 +77,16 @@ export { VerticalProblem } from './VerticalProblem'
export type { StudentView } from './ViewSelector'
export { ViewSelector, VIEW_CONFIGS, getAvailableViews, getDefaultView } from './ViewSelector'
export { VirtualizedSessionList } from './VirtualizedSessionList'
// Part transition components
export type { PartTransitionScreenProps } from './PartTransitionScreen'
export { PartTransitionScreen, TRANSITION_COUNTDOWN_MS } from './PartTransitionScreen'
export type { ObserverTransitionViewProps } from './ObserverTransitionView'
export { ObserverTransitionView } from './ObserverTransitionView'
export {
selectTransitionMessage,
getTransitionType,
requiresAbacusPutAway,
requiresAbacusPickUp,
type TransitionMessage,
type TransitionType,
} from './partTransitionMessages'

View File

@@ -0,0 +1,167 @@
/**
* Message pools for part transition screens
*
* Messages are organized by transition type and randomly selected
* to keep daily practice sessions feeling fresh.
*/
import type { SessionPartType } from '@/db/schema/session-plans'
// ============================================================================
// Types
// ============================================================================
export interface TransitionMessage {
/** Main headline text */
headline: string
/** Optional subtitle with additional instruction */
subtitle?: string
}
export type TransitionType =
| 'start-to-abacus'
| 'abacus-to-visualization'
| 'visualization-to-linear'
| 'start-to-visualization'
| 'start-to-linear'
| 'abacus-to-linear'
// ============================================================================
// Message Pools
// ============================================================================
const START_TO_ABACUS_MESSAGES: TransitionMessage[] = [
{ headline: 'Get Ready!', subtitle: 'Grab your abacus' },
{ headline: "Let's Begin!", subtitle: 'Have your abacus ready' },
{ headline: 'Abacus Time', subtitle: 'Get your beads ready' },
]
const ABACUS_TO_VISUALIZATION_MESSAGES: TransitionMessage[] = [
{ headline: 'Mental Math Time!', subtitle: 'Put your abacus aside' },
{ headline: 'Visualization Mode', subtitle: 'Picture the beads in your mind' },
{ headline: 'Abacus Break', subtitle: 'Set it down gently' },
{ headline: 'Mind Over Beads', subtitle: 'Time to imagine' },
{ headline: 'Close Your Eyes...', subtitle: 'See the beads in your head' },
]
const VISUALIZATION_TO_LINEAR_MESSAGES: TransitionMessage[] = [
{ headline: 'Equation Mode!', subtitle: 'Same math, different look' },
{ headline: 'Reading Problems', subtitle: 'Like a math sentence' },
{ headline: 'Linear Style', subtitle: 'Left to right' },
{ headline: 'Number Sentences', subtitle: 'Quick mental math' },
]
const START_TO_VISUALIZATION_MESSAGES: TransitionMessage[] = [
{ headline: 'Mental Math!', subtitle: 'No abacus needed today' },
{ headline: 'Visualization Time', subtitle: 'Picture the beads' },
]
const START_TO_LINEAR_MESSAGES: TransitionMessage[] = [
{ headline: 'Quick Math!', subtitle: 'Solve these equations' },
{ headline: "Let's Go!", subtitle: 'Number sentences ahead' },
]
const ABACUS_TO_LINEAR_MESSAGES: TransitionMessage[] = [
{ headline: 'Equation Time!', subtitle: 'Put your abacus away' },
{ headline: 'Linear Mode', subtitle: 'No more beads for now' },
]
// ============================================================================
// Message Selection
// ============================================================================
/**
* Get the transition type based on previous and next part types
*/
export function getTransitionType(
previousPartType: SessionPartType | null,
nextPartType: SessionPartType
): TransitionType {
if (previousPartType === null) {
// Session start
switch (nextPartType) {
case 'abacus':
return 'start-to-abacus'
case 'visualization':
return 'start-to-visualization'
case 'linear':
return 'start-to-linear'
}
}
if (previousPartType === 'abacus' && nextPartType === 'visualization') {
return 'abacus-to-visualization'
}
if (previousPartType === 'visualization' && nextPartType === 'linear') {
return 'visualization-to-linear'
}
if (previousPartType === 'abacus' && nextPartType === 'linear') {
return 'abacus-to-linear'
}
// Fallback (shouldn't happen in normal flow)
return 'visualization-to-linear'
}
/**
* Get the message pool for a transition type
*/
function getMessagePool(transitionType: TransitionType): TransitionMessage[] {
switch (transitionType) {
case 'start-to-abacus':
return START_TO_ABACUS_MESSAGES
case 'abacus-to-visualization':
return ABACUS_TO_VISUALIZATION_MESSAGES
case 'visualization-to-linear':
return VISUALIZATION_TO_LINEAR_MESSAGES
case 'start-to-visualization':
return START_TO_VISUALIZATION_MESSAGES
case 'start-to-linear':
return START_TO_LINEAR_MESSAGES
case 'abacus-to-linear':
return ABACUS_TO_LINEAR_MESSAGES
}
}
/**
* Select a random message for a transition
*
* Uses a simple random selection. For a deterministic selection
* (e.g., based on session ID), pass a seed.
*/
export function selectTransitionMessage(
previousPartType: SessionPartType | null,
nextPartType: SessionPartType,
seed?: number
): TransitionMessage {
const transitionType = getTransitionType(previousPartType, nextPartType)
const pool = getMessagePool(transitionType)
// Use seed if provided, otherwise random
const index =
seed !== undefined ? Math.abs(seed) % pool.length : Math.floor(Math.random() * pool.length)
return pool[index]
}
/**
* Check if a transition requires putting away the abacus
*/
export function requiresAbacusPutAway(
previousPartType: SessionPartType | null,
nextPartType: SessionPartType
): boolean {
if (previousPartType === null) return false
return previousPartType === 'abacus' && nextPartType !== 'abacus'
}
/**
* Check if a transition requires getting the abacus
*/
export function requiresAbacusPickUp(
previousPartType: SessionPartType | null,
nextPartType: SessionPartType
): boolean {
if (previousPartType === null) return nextPartType === 'abacus'
return previousPartType !== 'abacus' && nextPartType === 'abacus'
}

View File

@@ -35,6 +35,7 @@ export interface AvailableActions {
enterClassroom: boolean
leaveClassroom: boolean
removeFromClassroom: boolean
promptToEnter: boolean
// Enrollment actions
enrollInClassroom: boolean
@@ -77,6 +78,7 @@ export function getAvailableActions(
enterClassroom: false,
leaveClassroom: false,
removeFromClassroom: false,
promptToEnter: false,
enrollInClassroom: false,
unenrollStudent: false,
shareAccess: false,
@@ -94,6 +96,8 @@ export function getAvailableActions(
leaveClassroom: isMyChild && isPresent,
// Teachers can remove students from their classroom
removeFromClassroom: isTeacher && isPresent,
// Teachers can prompt parents to enter their enrolled students
promptToEnter: isTeacher && isEnrolled && !isPresent,
// Enrollment actions
// Parents can enroll their children (even if they're also teachers)
@@ -116,6 +120,7 @@ export const ACTION_DEFINITIONS = {
enterClassroom: { icon: '🏫', label: 'Enter Classroom' },
leaveClassroom: { icon: '🚪', label: 'Leave Classroom' },
removeFromClassroom: { icon: '🚪', label: 'Remove from Classroom' },
promptToEnter: { icon: '📣', label: 'Prompt to Enter' },
enrollInClassroom: { icon: '', label: 'Enroll in Classroom' },
unenrollStudent: { icon: '📋', label: 'Unenroll Student', variant: 'danger' as const },
shareAccess: { icon: '🔗', label: 'Share Access' },

View File

@@ -32,6 +32,9 @@ export const classrooms = sqliteTable(
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
/** Default expiry time for entry prompts (in minutes). Null = use system default (30 min) */
entryPromptExpiryMinutes: integer('entry_prompt_expiry_minutes'),
},
(table) => ({
/** Index for looking up classroom by code */

View File

@@ -0,0 +1,95 @@
import { createId } from '@paralleldrive/cuid2'
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { classrooms } from './classrooms'
import { players } from './players'
import { users } from './users'
/**
* Entry prompt status
*/
export type EntryPromptStatus = 'pending' | 'accepted' | 'declined' | 'expired'
/**
* Entry prompts - teacher requests for parent to have child enter classroom
*
* Teachers can prompt parents to have their enrolled child enter the classroom.
* Prompts have an expiry time and parents can accept or decline.
*
* - Accept: Child is entered into classroom (presence record created)
* - Decline: Teacher is notified, child stays out
* - Expire: Prompt auto-dismisses (client-side based on expiresAt)
*/
export const entryPrompts = sqliteTable(
'entry_prompts',
{
/** Primary key */
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
/** Teacher who sent the prompt */
teacherId: text('teacher_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** Student (player) to be prompted to enter */
playerId: text('player_id')
.notNull()
.references(() => players.id, { onDelete: 'cascade' }),
/** Classroom to enter */
classroomId: text('classroom_id')
.notNull()
.references(() => classrooms.id, { onDelete: 'cascade' }),
/** When the prompt expires (ISO timestamp) */
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
/** Current status of the prompt */
status: text('status').notNull().default('pending').$type<EntryPromptStatus>(),
/** Parent who responded (if any) */
respondedBy: text('responded_by').references(() => users.id),
/** When parent responded */
respondedAt: integer('responded_at', { mode: 'timestamp' }),
/** When the prompt was created */
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
/** Index for finding prompts by teacher */
teacherIdx: index('idx_entry_prompts_teacher').on(table.teacherId),
/** Index for finding prompts by player */
playerIdx: index('idx_entry_prompts_player').on(table.playerId),
/** Index for finding prompts by classroom */
classroomIdx: index('idx_entry_prompts_classroom').on(table.classroomId),
/** Index for filtering by status */
statusIdx: index('idx_entry_prompts_status').on(table.status),
// Note: Partial unique index (only one pending per player/classroom)
// is created in migration SQL directly since Drizzle doesn't support WHERE clauses
})
)
export type EntryPrompt = typeof entryPrompts.$inferSelect
export type NewEntryPrompt = typeof entryPrompts.$inferInsert
/**
* Check if a prompt has expired
*/
export function isPromptExpired(prompt: EntryPrompt): boolean {
return prompt.expiresAt < new Date()
}
/**
* Check if a prompt is still active (pending and not expired)
*/
export function isPromptActive(prompt: EntryPrompt): boolean {
return prompt.status === 'pending' && !isPromptExpired(prompt)
}

View File

@@ -14,6 +14,7 @@ export * from './classroom-presence'
export * from './classrooms'
export * from './custom-skills'
export * from './enrollment-requests'
export * from './entry-prompts'
export * from './parent-child'
export * from './player-curriculum'
export * from './player-skill-mastery'

View File

@@ -124,6 +124,45 @@ export function useIsTeacher() {
}
}
/**
* Update classroom settings
*/
export interface UpdateClassroomParams {
name?: string
entryPromptExpiryMinutes?: number | null
regenerateCode?: boolean
}
async function updateClassroom(
classroomId: string,
params: UpdateClassroomParams
): Promise<Classroom> {
const res = await api(`classrooms/${classroomId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to update classroom')
}
const data = await res.json()
return data.classroom
}
export function useUpdateClassroom() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ classroomId, ...params }: UpdateClassroomParams & { classroomId: string }) =>
updateClassroom(classroomId, params),
onSuccess: (classroom) => {
// Update the 'mine' query with the updated classroom
queryClient.setQueryData(classroomKeys.mine(), classroom)
},
})
}
// ============================================================================
// Enrollment API Functions
// ============================================================================
@@ -591,6 +630,8 @@ export interface ActiveSessionInfo {
totalProblems: number
/** Number of completed problems */
completedProblems: number
/** Whether the student is currently present in the classroom */
isPresent?: boolean
}
/**

View File

@@ -0,0 +1,113 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/queryClient'
import { entryPromptKeys } from '@/lib/queryKeys'
export interface EntryPrompt {
id: string
teacherId: string
playerId: string
classroomId: string
expiresAt: string
status: 'pending' | 'accepted' | 'declined' | 'expired'
createdAt: string
player: {
id: string
name: string
emoji: string
}
classroom: {
id: string
name: string
}
teacher: {
displayName: string
}
}
/**
* Hook for parents to manage entry prompts for their children
*
* - Fetches pending entry prompts
* - Provides accept/decline mutations
* - Real-time updates handled by useParentSocket via query invalidation
*/
export function useEntryPrompts(enabled = true) {
const queryClient = useQueryClient()
// Fetch pending prompts
const {
data: prompts = [],
isLoading,
error,
} = useQuery({
queryKey: entryPromptKeys.pending(),
queryFn: async () => {
const response = await api('entry-prompts')
if (!response.ok) {
throw new Error('Failed to fetch entry prompts')
}
const data = await response.json()
return data.prompts as EntryPrompt[]
},
enabled,
refetchInterval: 30000, // Refresh every 30s to catch expired prompts
})
// Accept mutation
const acceptPrompt = useMutation({
mutationFn: async (promptId: string) => {
const response = await api(`entry-prompts/${promptId}/respond`, {
method: 'POST',
body: JSON.stringify({ action: 'accept' }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to accept prompt')
}
return response.json()
},
onSuccess: () => {
// Invalidate prompts query to refresh the list
queryClient.invalidateQueries({ queryKey: entryPromptKeys.pending() })
},
})
// Decline mutation
const declinePrompt = useMutation({
mutationFn: async (promptId: string) => {
const response = await api(`entry-prompts/${promptId}/respond`, {
method: 'POST',
body: JSON.stringify({ action: 'decline' }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to decline prompt')
}
return response.json()
},
onSuccess: () => {
// Invalidate prompts query to refresh the list
queryClient.invalidateQueries({ queryKey: entryPromptKeys.pending() })
},
})
// Filter out expired prompts on the client side
const activePrompts = prompts.filter((p) => {
const expiresAt = new Date(p.expiresAt)
return expiresAt > new Date() && p.status === 'pending'
})
return {
prompts: activePrompts,
isLoading,
error,
acceptPrompt: acceptPrompt.mutateAsync,
declinePrompt: declinePrompt.mutateAsync,
isAccepting: acceptPrompt.isPending,
isDeclining: declinePrompt.isPending,
acceptingPromptId: acceptPrompt.isPending ? acceptPrompt.variables : null,
decliningPromptId: declinePrompt.isPending ? declinePrompt.variables : null,
}
}

View File

@@ -9,6 +9,9 @@ import type {
EnrollmentRequestApprovedEvent,
EnrollmentRequestCreatedEvent,
EnrollmentRequestDeniedEvent,
EntryPromptAcceptedEvent,
EntryPromptCreatedEvent,
EntryPromptDeclinedEvent,
StudentUnenrolledEvent,
} from '@/lib/classroom/socket-events'
@@ -111,6 +114,40 @@ export function useParentSocket(userId: string | undefined): { connected: boolea
})
})
// Listen for entry prompt created event (teacher wants child to enter classroom)
socket.on('entry-prompt-created', (data: EntryPromptCreatedEvent) => {
console.log(
'[ParentSocket] Entry prompt from:',
data.teacherName,
'for:',
data.playerName,
'to enter:',
data.classroomName
)
invalidateForEvent(queryClient, 'entryPromptCreated', {
classroomId: data.classroomId,
playerId: data.playerId,
})
})
// Listen for entry prompt accepted event (another parent accepted)
socket.on('entry-prompt-accepted', (data: EntryPromptAcceptedEvent) => {
console.log('[ParentSocket] Entry prompt accepted for:', data.playerName)
invalidateForEvent(queryClient, 'entryPromptAccepted', {
classroomId: data.classroomId,
playerId: data.playerId,
})
})
// Listen for entry prompt declined event (another parent declined)
socket.on('entry-prompt-declined', (data: EntryPromptDeclinedEvent) => {
console.log('[ParentSocket] Entry prompt declined for:', data.playerName)
invalidateForEvent(queryClient, 'entryPromptDeclined', {
classroomId: data.classroomId,
playerId: data.playerId,
})
})
// Cleanup on unmount
return () => {
socket.disconnect()

View File

@@ -3,8 +3,11 @@
import { useCallback, useEffect, useRef } from 'react'
import { io, type Socket } from 'socket.io-client'
import type { BroadcastState } from '@/components/practice'
import type { SessionPartType } from '@/db/schema/session-plans'
import type {
AbacusControlEvent,
PartTransitionCompleteEvent,
PartTransitionEvent,
PracticeStateEvent,
SessionPausedEvent,
SessionResumedEvent,
@@ -49,12 +52,26 @@ export interface UseSessionBroadcastOptions {
* @param state - Current practice state (or null if not in active practice)
* @param options - Optional callbacks for receiving observer control events
*/
export interface UseSessionBroadcastResult {
isConnected: boolean
isBroadcasting: boolean
/** Send part transition event to observers */
sendPartTransition: (
previousPartType: SessionPartType | null,
nextPartType: SessionPartType,
countdownStartTime: number,
countdownDurationMs: number
) => void
/** Send part transition complete event to observers */
sendPartTransitionComplete: () => void
}
export function useSessionBroadcast(
sessionId: string | undefined,
playerId: string | undefined,
state: BroadcastState | null,
options?: UseSessionBroadcastOptions
): { isConnected: boolean; isBroadcasting: boolean } {
): UseSessionBroadcastResult {
const socketRef = useRef<Socket | null>(null)
const isConnectedRef = useRef(false)
// Keep state in a ref so socket event handlers can access current state
@@ -210,8 +227,54 @@ export function useSessionBroadcast(
state?.purpose, // Purpose change
])
// Broadcast part transition to observers
const sendPartTransition = useCallback(
(
previousPartType: SessionPartType | null,
nextPartType: SessionPartType,
countdownStartTime: number,
countdownDurationMs: number
) => {
if (!socketRef.current || !isConnectedRef.current || !sessionId) {
return
}
const event: PartTransitionEvent = {
sessionId,
previousPartType,
nextPartType,
countdownStartTime,
countdownDurationMs,
}
socketRef.current.emit('part-transition', event)
console.log('[SessionBroadcast] Emitted part-transition:', {
previousPartType,
nextPartType,
countdownDurationMs,
})
},
[sessionId]
)
// Broadcast part transition complete to observers
const sendPartTransitionComplete = useCallback(() => {
if (!socketRef.current || !isConnectedRef.current || !sessionId) {
return
}
const event: PartTransitionCompleteEvent = {
sessionId,
}
socketRef.current.emit('part-transition-complete', event)
console.log('[SessionBroadcast] Emitted part-transition-complete')
}, [sessionId])
return {
isConnected: isConnectedRef.current,
isBroadcasting: isConnectedRef.current && !!state,
sendPartTransition,
sendPartTransitionComplete,
}
}

View File

@@ -2,9 +2,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { io, type Socket } from 'socket.io-client'
import type { SessionPart, SlotResult } from '@/db/schema/session-plans'
import type { SessionPart, SessionPartType, SlotResult } from '@/db/schema/session-plans'
import type {
AbacusControlEvent,
PartTransitionCompleteEvent,
PartTransitionEvent,
PracticeStateEvent,
SessionPausedEvent,
SessionResumedEvent,
@@ -72,6 +74,20 @@ export interface ObservedSessionState {
slotResults?: SlotResult[]
}
/**
* State of a part transition being observed
*/
export interface ObservedTransitionState {
/** Part type transitioning FROM (null if session start) */
previousPartType: SessionPartType | null
/** Part type transitioning TO */
nextPartType: SessionPartType
/** Timestamp when countdown started (for sync) */
countdownStartTime: number
/** Countdown duration in ms */
countdownDurationMs: number
}
/**
* A recorded result from a completed problem during observation
*/
@@ -99,6 +115,8 @@ interface UseSessionObserverResult {
state: ObservedSessionState | null
/** Accumulated results from completed problems */
results: ObservedResult[]
/** Current part transition state (null if not in transition) */
transitionState: ObservedTransitionState | null
/** Whether connected to the session channel */
isConnected: boolean
/** Whether actively observing (connected and joined session) */
@@ -136,6 +154,7 @@ export function useSessionObserver(
): UseSessionObserverResult {
const [state, setState] = useState<ObservedSessionState | null>(null)
const [results, setResults] = useState<ObservedResult[]>([])
const [transitionState, setTransitionState] = useState<ObservedTransitionState | null>(null)
const [isConnected, setIsConnected] = useState(false)
const [isObserving, setIsObserving] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -310,6 +329,28 @@ export function useSessionObserver(
})
})
// Listen for part transition events
socket.on('part-transition', (data: PartTransitionEvent) => {
console.log('[SessionObserver] Received part-transition:', {
previousPartType: data.previousPartType,
nextPartType: data.nextPartType,
countdownDurationMs: data.countdownDurationMs,
})
setTransitionState({
previousPartType: data.previousPartType,
nextPartType: data.nextPartType,
countdownStartTime: data.countdownStartTime,
countdownDurationMs: data.countdownDurationMs,
})
})
// Listen for part transition complete events
socket.on('part-transition-complete', (_data: PartTransitionCompleteEvent) => {
console.log('[SessionObserver] Part transition complete')
setTransitionState(null)
})
// Listen for session ended event
socket.on('session-ended', () => {
console.log('[SessionObserver] Session ended')
@@ -400,6 +441,7 @@ export function useSessionObserver(
return {
state,
results,
transitionState,
isConnected,
isObserving,
error,

View File

@@ -2,6 +2,7 @@
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import {
useEnrolledClassrooms,
useEnterClassroom,
@@ -16,6 +17,7 @@ import {
type AvailableActions,
type StudentActionData,
} from '@/components/practice/studentActions'
import { api } from '@/lib/queryClient'
export type { StudentActionData, AvailableActions }
@@ -25,6 +27,7 @@ export interface StudentActionHandlers {
enterClassroom: () => Promise<void>
enterSpecificClassroom: (classroomId: string) => Promise<void>
leaveClassroom: () => Promise<void>
promptToEnter: () => Promise<void>
toggleArchive: () => Promise<void>
openShareAccess: () => void
openEnrollModal: () => void
@@ -101,6 +104,21 @@ export function useStudentActions(
const enterClassroom = useEnterClassroom()
const leaveClassroom = useLeaveClassroom()
// Entry prompt mutation (teacher action)
const createEntryPrompt = useMutation({
mutationFn: async ({ classroomId, playerId }: { classroomId: string; playerId: string }) => {
const response = await api(`classrooms/${classroomId}/entry-prompts`, {
method: 'POST',
body: JSON.stringify({ playerIds: [playerId] }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to send prompt')
}
return response.json()
},
})
// ========== Modal state ==========
const [showShareAccess, setShowShareAccess] = useState(false)
const [showEnrollModal, setShowEnrollModal] = useState(false)
@@ -160,6 +178,15 @@ export function useStudentActions(
})
}, [student.id, student.isArchived, updatePlayer])
const handlePromptToEnter = useCallback(async () => {
if (classroom?.id) {
await createEntryPrompt.mutateAsync({
classroomId: classroom.id,
playerId: student.id,
})
}
}, [classroom?.id, createEntryPrompt, student.id])
// ========== Memoized result ==========
const handlers: StudentActionHandlers = useMemo(
() => ({
@@ -168,6 +195,7 @@ export function useStudentActions(
enterClassroom: handleEnterClassroom,
enterSpecificClassroom: handleEnterSpecificClassroom,
leaveClassroom: handleLeaveClassroom,
promptToEnter: handlePromptToEnter,
toggleArchive: handleToggleArchive,
openShareAccess: () => setShowShareAccess(true),
openEnrollModal: () => setShowEnrollModal(true),
@@ -178,6 +206,7 @@ export function useStudentActions(
handleEnterClassroom,
handleEnterSpecificClassroom,
handleLeaveClassroom,
handlePromptToEnter,
handleToggleArchive,
]
)
@@ -198,7 +227,11 @@ export function useStudentActions(
[showShareAccess, showEnrollModal]
)
const isLoading = updatePlayer.isPending || enterClassroom.isPending || leaveClassroom.isPending
const isLoading =
updatePlayer.isPending ||
enterClassroom.isPending ||
leaveClassroom.isPending ||
createEntryPrompt.isPending
// ========== Classroom data ==========
const classrooms: ClassroomData = useMemo(

View File

@@ -0,0 +1,291 @@
/**
* Unit tests for entry prompts functionality
*
* Tests the classroom entry prompt system where teachers can request
* parents to have their children enter the classroom.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mock the database
const mockDb = {
query: {
entryPrompts: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
classrooms: {
findFirst: vi.fn(),
},
players: {
findFirst: vi.fn(),
},
users: {
findFirst: vi.fn(),
},
classroomEnrollments: {
findMany: vi.fn(),
},
classroomPresence: {
findMany: vi.fn(),
},
},
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
}
vi.mock('@/db', () => ({
db: mockDb,
schema: {
entryPrompts: {
id: 'id',
teacherId: 'teacher_id',
playerId: 'player_id',
classroomId: 'classroom_id',
expiresAt: 'expires_at',
status: 'status',
respondedBy: 'responded_by',
respondedAt: 'responded_at',
createdAt: 'created_at',
},
classrooms: {
id: 'id',
teacherId: 'teacher_id',
name: 'name',
code: 'code',
entryPromptExpiryMinutes: 'entry_prompt_expiry_minutes',
},
classroomEnrollments: {
playerId: 'player_id',
classroomId: 'classroom_id',
},
classroomPresence: {
playerId: 'player_id',
classroomId: 'classroom_id',
},
players: {
id: 'id',
name: 'name',
emoji: 'emoji',
},
users: {
id: 'id',
name: 'name',
},
},
}))
describe('Entry Prompts', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Expiry Time Calculation', () => {
const DEFAULT_EXPIRY_MINUTES = 30
it('uses system default (30 min) when no classroom setting', () => {
const classroom = {
id: 'classroom-1',
teacherId: 'teacher-1',
entryPromptExpiryMinutes: null,
}
const requestOverride = undefined
const expiresInMinutes =
requestOverride || classroom.entryPromptExpiryMinutes || DEFAULT_EXPIRY_MINUTES
expect(expiresInMinutes).toBe(30)
})
it('uses classroom setting when configured', () => {
const classroom = {
id: 'classroom-1',
teacherId: 'teacher-1',
entryPromptExpiryMinutes: 60,
}
const requestOverride = undefined
const expiresInMinutes =
requestOverride || classroom.entryPromptExpiryMinutes || DEFAULT_EXPIRY_MINUTES
expect(expiresInMinutes).toBe(60)
})
it('request override takes precedence over classroom setting', () => {
const classroom = {
id: 'classroom-1',
teacherId: 'teacher-1',
entryPromptExpiryMinutes: 60,
}
const requestOverride = 15
const expiresInMinutes =
requestOverride || classroom.entryPromptExpiryMinutes || DEFAULT_EXPIRY_MINUTES
expect(expiresInMinutes).toBe(15)
})
it('calculates correct expiry date', () => {
const now = new Date('2025-01-01T12:00:00Z')
const expiresInMinutes = 45
const expiresAt = new Date(now.getTime() + expiresInMinutes * 60 * 1000)
expect(expiresAt.toISOString()).toBe('2025-01-01T12:45:00.000Z')
})
})
describe('Prompt Status Transitions', () => {
it('pending prompt can transition to accepted', () => {
const validTransitions: Record<string, string[]> = {
pending: ['accepted', 'declined', 'expired'],
accepted: [],
declined: [],
expired: [],
}
expect(validTransitions.pending).toContain('accepted')
})
it('pending prompt can transition to declined', () => {
const validTransitions: Record<string, string[]> = {
pending: ['accepted', 'declined', 'expired'],
accepted: [],
declined: [],
expired: [],
}
expect(validTransitions.pending).toContain('declined')
})
it('accepted prompt cannot transition further', () => {
const validTransitions: Record<string, string[]> = {
pending: ['accepted', 'declined', 'expired'],
accepted: [],
declined: [],
expired: [],
}
expect(validTransitions.accepted).toHaveLength(0)
})
})
describe('Eligibility Rules', () => {
it('student must be enrolled to receive prompt', () => {
const enrolledPlayerIds = new Set(['player-1', 'player-2'])
const playerId = 'player-3'
const isEnrolled = enrolledPlayerIds.has(playerId)
expect(isEnrolled).toBe(false)
})
it('student already present cannot receive prompt', () => {
const presentPlayerIds = new Set(['player-1'])
const playerId = 'player-1'
const isPresent = presentPlayerIds.has(playerId)
expect(isPresent).toBe(true)
})
it('student with pending prompt cannot receive another', () => {
const existingPromptPlayerIds = new Set(['player-1'])
const playerId = 'player-1'
const hasPendingPrompt = existingPromptPlayerIds.has(playerId)
expect(hasPendingPrompt).toBe(true)
})
it('eligible student can receive prompt', () => {
const enrolledPlayerIds = new Set(['player-1', 'player-2', 'player-3'])
const presentPlayerIds = new Set(['player-1'])
const existingPromptPlayerIds = new Set(['player-2'])
const playerId = 'player-3'
const isEnrolled = enrolledPlayerIds.has(playerId)
const isPresent = presentPlayerIds.has(playerId)
const hasPendingPrompt = existingPromptPlayerIds.has(playerId)
const isEligible = isEnrolled && !isPresent && !hasPendingPrompt
expect(isEligible).toBe(true)
})
})
describe('Expiry Detection', () => {
it('filters out expired prompts', () => {
const now = new Date('2025-01-01T12:00:00Z')
const prompts = [
{ id: 'prompt-1', expiresAt: new Date('2025-01-01T11:00:00Z'), status: 'pending' }, // expired
{ id: 'prompt-2', expiresAt: new Date('2025-01-01T13:00:00Z'), status: 'pending' }, // active
{ id: 'prompt-3', expiresAt: new Date('2025-01-01T12:30:00Z'), status: 'pending' }, // active
]
const activePrompts = prompts.filter((p) => p.expiresAt > now)
expect(activePrompts).toHaveLength(2)
expect(activePrompts.map((p) => p.id)).toEqual(['prompt-2', 'prompt-3'])
})
it('expired prompt does NOT block creating a new prompt', () => {
const now = new Date('2025-01-01T12:00:00Z')
// Simulate existing prompts in database
const existingPrompts = [
{
id: 'prompt-1',
playerId: 'player-1',
expiresAt: new Date('2025-01-01T11:00:00Z'), // EXPIRED
status: 'pending',
},
]
// Filter out expired prompts when checking for duplicates
const activeExistingPrompts = existingPrompts.filter((p) => p.expiresAt > now)
const existingPromptPlayerIds = new Set(activeExistingPrompts.map((p) => p.playerId))
// Player 1's expired prompt should NOT block a new prompt
expect(existingPromptPlayerIds.has('player-1')).toBe(false)
// Can create new prompt for player-1
const playerId = 'player-1'
const hasPendingPrompt = existingPromptPlayerIds.has(playerId)
expect(hasPendingPrompt).toBe(false) // Expired prompts don't count
})
})
})
describe('Classroom Settings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Entry Prompt Expiry Setting', () => {
it('validates positive integer for expiry minutes', () => {
const validValues = [15, 30, 45, 60, 90, 120]
for (const value of validValues) {
expect(typeof value === 'number' && value > 0).toBe(true)
}
})
it('accepts null to use system default', () => {
const value: number | null = null
const isValid = value === null || (typeof value === 'number' && value > 0)
expect(isValid).toBe(true)
})
it('rejects zero or negative values', () => {
const invalidValues = [0, -1, -30]
for (const value of invalidValues) {
const isValid = value === null || (typeof value === 'number' && value > 0)
expect(isValid).toBe(false)
}
})
})
})

View File

@@ -36,6 +36,8 @@ export interface PlayerAccess {
isParent: boolean
isTeacher: boolean
isPresent: boolean
/** Classroom ID if the viewer is a teacher */
classroomId?: string
}
/**
@@ -86,7 +88,7 @@ export async function getPlayerAccess(viewerId: string, playerId: string): Promi
accessLevel = 'teacher-enrolled'
}
return { playerId, accessLevel, isParent, isTeacher, isPresent }
return { playerId, accessLevel, isParent, isTeacher, isPresent, classroomId: classroom?.id }
}
/**

View File

@@ -155,6 +155,8 @@ export async function getClassroomByCode(code: string): Promise<ClassroomWithTea
export interface UpdateClassroomParams {
name?: string
/** Entry prompt expiry time in minutes. Null = use system default (30 min) */
entryPromptExpiryMinutes?: number | null
}
/**

View File

@@ -17,7 +17,7 @@
*/
import type { QueryClient } from '@tanstack/react-query'
import { classroomKeys, playerKeys } from '@/lib/queryKeys'
import { classroomKeys, entryPromptKeys, playerKeys } from '@/lib/queryKeys'
/**
* Event types that trigger query invalidations
@@ -32,6 +32,9 @@ export type ClassroomEventType =
| 'studentLeft'
| 'sessionStarted'
| 'sessionEnded'
| 'entryPromptCreated'
| 'entryPromptAccepted'
| 'entryPromptDeclined'
/**
* Parameters for invalidation - each event type may need different params
@@ -181,6 +184,21 @@ export function invalidateForEvent(
}
break
case 'entryPromptCreated':
case 'entryPromptAccepted':
case 'entryPromptDeclined':
// Parent's pending entry prompts list updates
queryClient.invalidateQueries({
queryKey: entryPromptKeys.pending(),
})
// If a prompt was accepted, also update classroom presence
if (event === 'entryPromptAccepted' && classroomId) {
queryClient.invalidateQueries({
queryKey: classroomKeys.presence(classroomId),
})
}
break
default: {
// Exhaustive check - if we hit this, we're missing a case
const _exhaustive: never = event
@@ -267,6 +285,15 @@ export function getInvalidationKeys(
}
break
case 'entryPromptCreated':
case 'entryPromptAccepted':
case 'entryPromptDeclined':
keys.push(entryPromptKeys.pending())
if (event === 'entryPromptAccepted' && classroomId) {
keys.push(classroomKeys.presence(classroomId))
}
break
default: {
const _exhaustive: never = event
console.error('[QueryInvalidations] Unknown event type:', _exhaustive)

View File

@@ -16,6 +16,9 @@ import type {
EnrollmentRequestApprovedEvent,
EnrollmentRequestCreatedEvent,
EnrollmentRequestDeniedEvent,
EntryPromptAcceptedEvent,
EntryPromptCreatedEvent,
EntryPromptDeclinedEvent,
SessionEndedEvent,
SessionStartedEvent,
StudentUnenrolledEvent,
@@ -322,3 +325,118 @@ export async function emitSessionEndedToPlayer(
console.error('[SocketEmitter] Failed to emit session-ended to player:', error)
}
}
// ============================================================================
// Entry Prompt Events
// ============================================================================
/**
* Entry prompt event payload
*/
export interface EntryPromptPayload {
promptId: string
classroomId: string
classroomName: string
playerId: string
playerName: string
playerEmoji: string
}
/**
* Emit an entry prompt created event
*
* Use when: A teacher creates an entry prompt for a student.
* This notifies all parents of the student to prompt them to enter the child.
*/
export async function emitEntryPromptCreated(
payload: EntryPromptPayload & { teacherName: string; expiresAt: Date },
parentIds: string[]
): Promise<void> {
const io = await getSocketIO()
if (!io) return
const eventData: EntryPromptCreatedEvent = {
promptId: payload.promptId,
classroomId: payload.classroomId,
classroomName: payload.classroomName,
playerId: payload.playerId,
playerName: payload.playerName,
playerEmoji: payload.playerEmoji,
teacherName: payload.teacherName,
expiresAt: payload.expiresAt.toISOString(),
}
try {
// Emit to each parent's user channel
for (const parentId of parentIds) {
io.to(`user:${parentId}`).emit('entry-prompt-created', eventData)
console.log(`[SocketEmitter] entry-prompt-created -> user:${parentId}`)
}
} catch (error) {
console.error('[SocketEmitter] Failed to emit entry-prompt-created:', error)
}
}
/**
* Emit an entry prompt accepted event
*
* Use when: A parent accepts an entry prompt, entering their child into the classroom.
* This notifies the teacher and updates the classroom presence view.
*/
export async function emitEntryPromptAccepted(
payload: Omit<EntryPromptPayload, 'playerEmoji'> & { acceptedBy: string },
teacherId: string
): Promise<void> {
const io = await getSocketIO()
if (!io) return
const eventData: EntryPromptAcceptedEvent = {
promptId: payload.promptId,
classroomId: payload.classroomId,
playerId: payload.playerId,
playerName: payload.playerName,
acceptedBy: payload.acceptedBy,
}
try {
// Emit to teacher's user channel
io.to(`user:${teacherId}`).emit('entry-prompt-accepted', eventData)
console.log(`[SocketEmitter] entry-prompt-accepted -> user:${teacherId}`)
// Also emit to classroom channel for real-time presence updates
io.to(`classroom:${payload.classroomId}`).emit('entry-prompt-accepted', eventData)
console.log(`[SocketEmitter] entry-prompt-accepted -> classroom:${payload.classroomId}`)
} catch (error) {
console.error('[SocketEmitter] Failed to emit entry-prompt-accepted:', error)
}
}
/**
* Emit an entry prompt declined event
*
* Use when: A parent declines an entry prompt.
* This only notifies the teacher (no need to update classroom).
*/
export async function emitEntryPromptDeclined(
payload: Omit<EntryPromptPayload, 'playerEmoji' | 'classroomName'> & { declinedBy: string },
teacherId: string
): Promise<void> {
const io = await getSocketIO()
if (!io) return
const eventData: EntryPromptDeclinedEvent = {
promptId: payload.promptId,
classroomId: payload.classroomId,
playerId: payload.playerId,
playerName: payload.playerName,
declinedBy: payload.declinedBy,
}
try {
// Emit only to teacher's user channel
io.to(`user:${teacherId}`).emit('entry-prompt-declined', eventData)
console.log(`[SocketEmitter] entry-prompt-declined -> user:${teacherId}`)
} catch (error) {
console.error('[SocketEmitter] Failed to emit entry-prompt-declined:', error)
}
}

View File

@@ -94,6 +94,52 @@ export interface EnrollmentDeniedEvent {
deniedBy: 'teacher' | 'parent'
}
// ============================================================================
// Entry Prompt Events (sent to user:${userId} channel)
// ============================================================================
/**
* Sent when a teacher creates an entry prompt for a student.
* Broadcast to all parents of the student.
*/
export interface EntryPromptCreatedEvent {
promptId: string
classroomId: string
classroomName: string
playerId: string
playerName: string
playerEmoji: string
teacherName: string
/** When the prompt expires (ISO timestamp) */
expiresAt: string
}
/**
* Sent when a parent accepts an entry prompt (child enters classroom).
* Broadcast to teacher and classroom channel.
*/
export interface EntryPromptAcceptedEvent {
promptId: string
classroomId: string
playerId: string
playerName: string
/** Parent who accepted */
acceptedBy: string
}
/**
* Sent when a parent declines an entry prompt.
* Broadcast only to teacher.
*/
export interface EntryPromptDeclinedEvent {
promptId: string
classroomId: string
playerId: string
playerName: string
/** Parent who declined */
declinedBy: string
}
// ============================================================================
// Presence Events (sent to classroom:${classroomId} channel)
// ============================================================================
@@ -199,6 +245,29 @@ export interface SessionResumedEvent {
sessionId: string
}
/**
* Sent when student transitions between session parts.
* Used to show observers the transition screen with synchronized countdown.
*/
export interface PartTransitionEvent {
sessionId: string
/** Part type we're transitioning FROM (null if session start) */
previousPartType: 'abacus' | 'visualization' | 'linear' | null
/** Part type we're transitioning TO */
nextPartType: 'abacus' | 'visualization' | 'linear'
/** Timestamp when countdown started (for sync) */
countdownStartTime: number
/** Countdown duration in ms */
countdownDurationMs: number
}
/**
* Sent when part transition completes (countdown finished or skipped)
*/
export interface PartTransitionCompleteEvent {
sessionId: string
}
/**
* Sent when a student starts a practice session while present in a classroom.
* Allows teacher to see session status update in real-time.
@@ -310,6 +379,11 @@ export interface ClassroomServerToClientEvents {
'student-unenrolled': (data: StudentUnenrolledEvent) => void
'enrollment-denied': (data: EnrollmentDeniedEvent) => void // deprecated
// Entry prompt events (user channel for parents, classroom channel for teacher)
'entry-prompt-created': (data: EntryPromptCreatedEvent) => void
'entry-prompt-accepted': (data: EntryPromptAcceptedEvent) => void
'entry-prompt-declined': (data: EntryPromptDeclinedEvent) => void
// Presence events (classroom channel)
'student-entered': (data: StudentEnteredEvent) => void
'student-left': (data: StudentLeftEvent) => void
@@ -325,6 +399,8 @@ export interface ClassroomServerToClientEvents {
'observer-joined': (data: ObserverJoinedEvent) => void
'session-paused': (data: SessionPausedEvent) => void
'session-resumed': (data: SessionResumedEvent) => void
'part-transition': (data: PartTransitionEvent) => void
'part-transition-complete': (data: PartTransitionCompleteEvent) => void
// Session status events (classroom channel - for teacher's active sessions view)
'session-started': (data: SessionStartedEvent) => void
@@ -357,6 +433,8 @@ export interface ClassroomClientToServerEvents {
'abacus-control': (data: AbacusControlEvent) => void
'session-pause': (data: SessionPausedEvent) => void
'session-resume': (data: SessionResumedEvent) => void
'part-transition': (data: PartTransitionEvent) => void
'part-transition-complete': (data: PartTransitionCompleteEvent) => void
// Skill tutorial broadcasts (from student client to classroom channel)
'skill-tutorial-state': (data: SkillTutorialStateEvent) => void

View File

@@ -52,3 +52,9 @@ export const classroomKeys = {
awaitingParentApproval: (id: string) =>
[...classroomKeys.detail(id), 'awaiting-parent-approval'] as const,
}
// Entry prompt query keys
export const entryPromptKeys = {
all: ['entry-prompts'] as const,
pending: () => [...entryPromptKeys.all, 'pending'] as const,
}