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:
22
apps/web/drizzle/0047_add_entry_prompts.sql
Normal file
22
apps/web/drizzle/0047_add_entry_prompts.sql
Normal 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';
|
||||
5
apps/web/drizzle/0048_ambitious_firedrake.sql
Normal file
5
apps/web/drizzle/0048_ambitious_firedrake.sql
Normal 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;
|
||||
1038
apps/web/drizzle/meta/0047_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0047_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0048_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0048_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
563
apps/web/e2e/entry-prompts.spec.ts
Normal file
563
apps/web/e2e/entry-prompts.spec.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
170
apps/web/src/app/api/entry-prompts/[promptId]/respond/route.ts
Normal file
170
apps/web/src/app/api/entry-prompts/[promptId]/respond/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
75
apps/web/src/app/api/entry-prompts/route.ts
Normal file
75
apps/web/src/app/api/entry-prompts/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}'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}'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}'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}'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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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}'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}'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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
295
apps/web/src/components/practice/EntryPromptBanner.tsx
Normal file
295
apps/web/src/components/practice/EntryPromptBanner.tsx
Normal 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)
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
262
apps/web/src/components/practice/ObserverTransitionView.tsx
Normal file
262
apps/web/src/components/practice/ObserverTransitionView.tsx
Normal 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
|
||||
438
apps/web/src/components/practice/PartTransitionScreen.tsx
Normal file
438
apps/web/src/components/practice/PartTransitionScreen.tsx
Normal 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
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
167
apps/web/src/components/practice/partTransitionMessages.ts
Normal file
167
apps/web/src/components/practice/partTransitionMessages.ts
Normal 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'
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 */
|
||||
|
||||
95
apps/web/src/db/schema/entry-prompts.ts
Normal file
95
apps/web/src/db/schema/entry-prompts.ts
Normal 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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
113
apps/web/src/hooks/useEntryPrompts.ts
Normal file
113
apps/web/src/hooks/useEntryPrompts.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
291
apps/web/src/lib/classroom/__tests__/entry-prompts.test.ts
Normal file
291
apps/web/src/lib/classroom/__tests__/entry-prompts.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user