From ccea0f86ac213b32cac7363f28e193b1976bd553 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 25 Dec 2025 18:43:15 -0600 Subject: [PATCH] feat(classroom): implement teacher-initiated pause and fix manual pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add teacher pause/resume functionality to SessionObserverModal - Extend PauseInfo interface with 'teacher' reason and teacherMessage - Handle session-paused/session-resumed WebSocket events in student client - Update SessionPausedModal UI for teacher-initiated pauses: - Show teacher emoji (👩‍🏫) and custom message - Disable resume button (only teacher can resume) - Fix manual pause not showing modal when clicking HUD dropdown - Add pause columns to session_plans schema (isPaused, pausedAt, pausedBy, pauseReason) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../0043_add_session_pause_columns.sql | 11 + apps/web/drizzle/meta/0043_snapshot.json | 1038 +++++++++++++++++ apps/web/drizzle/meta/_journal.json | 7 + .../practice/[studentId]/PracticeClient.tsx | 26 +- .../classroom/SessionObserverModal.tsx | 117 +- .../practice/ActiveSession.stories.tsx | 4 + .../src/components/practice/ActiveSession.tsx | 93 ++ .../practice/SessionPausedModal.stories.tsx | 4 + .../practice/SessionPausedModal.tsx | 68 +- .../practice/autoPauseCalculator.ts | 4 +- apps/web/src/db/schema/session-plans.ts | 14 + apps/web/src/hooks/useSessionBroadcast.ts | 35 +- apps/web/src/hooks/useSessionObserver.ts | 48 +- apps/web/src/lib/classroom/socket-events.ts | 9 + apps/web/src/socket-server.ts | 14 + 15 files changed, 1449 insertions(+), 43 deletions(-) create mode 100644 apps/web/drizzle/0043_add_session_pause_columns.sql create mode 100644 apps/web/drizzle/meta/0043_snapshot.json diff --git a/apps/web/drizzle/0043_add_session_pause_columns.sql b/apps/web/drizzle/0043_add_session_pause_columns.sql new file mode 100644 index 00000000..ab973ad4 --- /dev/null +++ b/apps/web/drizzle/0043_add_session_pause_columns.sql @@ -0,0 +1,11 @@ +-- Custom SQL migration file, put your code below! -- + +-- Note: These columns were manually added during development. +-- Migration kept for consistency but columns already exist. +-- Columns added to session_plans: +-- is_paused integer DEFAULT 0 NOT NULL +-- paused_at integer +-- paused_by text +-- paused_reason text + +SELECT 1; diff --git a/apps/web/drizzle/meta/0043_snapshot.json b/apps/web/drizzle/meta/0043_snapshot.json new file mode 100644 index 00000000..7567fea8 --- /dev/null +++ b/apps/web/drizzle/meta/0043_snapshot.json @@ -0,0 +1,1038 @@ +{ + "id": "8d55ee50-f015-4295-a47b-f3c4df581e8c", + "prevId": "06b85239-8220-470b-a5f9-e2882db31da5", + "version": "6", + "dialect": "sqlite", + "tables": { + "abacus_settings": { + "name": "abacus_settings", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "color_scheme": { + "name": "color_scheme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'place-value'" + }, + "bead_shape": { + "name": "bead_shape", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'diamond'" + }, + "color_palette": { + "name": "color_palette", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "hide_inactive_beads": { + "name": "hide_inactive_beads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "colored_numerals": { + "name": "colored_numerals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "scale_factor": { + "name": "scale_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "show_numbers": { + "name": "show_numbers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "animated": { + "name": "animated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "interactive": { + "name": "interactive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "gestures": { + "name": "gestures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sound_enabled": { + "name": "sound_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sound_volume": { + "name": "sound_volume", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0.8 + } + }, + "indexes": {}, + "foreignKeys": { + "abacus_settings_user_id_users_id_fk": { + "name": "abacus_settings_user_id_users_id_fk", + "tableFrom": "abacus_settings", + "columnsFrom": ["user_id"], + "tableTo": "users", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "arcade_rooms": { + "name": "arcade_rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_name": { + "name": "creator_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_activity": { + "name": "last_activity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ttl_minutes": { + "name": "ttl_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "is_locked": { + "name": "is_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "game_name": { + "name": "game_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_config": { + "name": "game_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lobby'" + }, + "current_session_id": { + "name": "current_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_games_played": { + "name": "total_games_played", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "arcade_rooms_code_unique": { + "name": "arcade_rooms_code_unique", + "columns": ["code"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "arcade_sessions": { + "name": "arcade_sessions", + "columns": { + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_game": { + "name": "current_game", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_url": { + "name": "game_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_state": { + "name": "game_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_players": { + "name": "active_players", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "arcade_sessions_room_id_arcade_rooms_id_fk": { + "name": "arcade_sessions_room_id_arcade_rooms_id_fk", + "tableFrom": "arcade_sessions", + "columnsFrom": ["room_id"], + "tableTo": "arcade_rooms", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "arcade_sessions_user_id_users_id_fk": { + "name": "arcade_sessions_user_id_users_id_fk", + "tableFrom": "arcade_sessions", + "columnsFrom": ["user_id"], + "tableTo": "users", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "players": { + "name": "players", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "players_user_id_idx": { + "name": "players_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "players_user_id_users_id_fk": { + "name": "players_user_id_users_id_fk", + "tableFrom": "players", + "columnsFrom": ["user_id"], + "tableTo": "users", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_members": { + "name": "room_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_creator": { + "name": "is_creator", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "joined_at": { + "name": "joined_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen": { + "name": "last_seen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_online": { + "name": "is_online", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "idx_room_members_user_id_unique": { + "name": "idx_room_members_user_id_unique", + "columns": ["user_id"], + "isUnique": true + } + }, + "foreignKeys": { + "room_members_room_id_arcade_rooms_id_fk": { + "name": "room_members_room_id_arcade_rooms_id_fk", + "tableFrom": "room_members", + "columnsFrom": ["room_id"], + "tableTo": "arcade_rooms", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_member_history": { + "name": "room_member_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_joined_at": { + "name": "first_joined_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_action": { + "name": "last_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "last_action_at": { + "name": "last_action_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_member_history_room_id_arcade_rooms_id_fk": { + "name": "room_member_history_room_id_arcade_rooms_id_fk", + "tableFrom": "room_member_history", + "columnsFrom": ["room_id"], + "tableTo": "arcade_rooms", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_invitations": { + "name": "room_invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by_name": { + "name": "invited_by_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "invitation_type": { + "name": "invitation_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "message": { + "name": "message", + "type": "text(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "responded_at": { + "name": "responded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_room_invitations_user_room": { + "name": "idx_room_invitations_user_room", + "columns": ["user_id", "room_id"], + "isUnique": true + } + }, + "foreignKeys": { + "room_invitations_room_id_arcade_rooms_id_fk": { + "name": "room_invitations_room_id_arcade_rooms_id_fk", + "tableFrom": "room_invitations", + "columnsFrom": ["room_id"], + "tableTo": "arcade_rooms", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_reports": { + "name": "room_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reporter_name": { + "name": "reporter_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reported_user_id": { + "name": "reported_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reported_user_name": { + "name": "reported_user_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_reports_room_id_arcade_rooms_id_fk": { + "name": "room_reports_room_id_arcade_rooms_id_fk", + "tableFrom": "room_reports", + "columnsFrom": ["room_id"], + "tableTo": "arcade_rooms", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_bans": { + "name": "room_bans", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "banned_by": { + "name": "banned_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "banned_by_name": { + "name": "banned_by_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_room_bans_user_room": { + "name": "idx_room_bans_user_room", + "columns": ["user_id", "room_id"], + "isUnique": true + } + }, + "foreignKeys": { + "room_bans_room_id_arcade_rooms_id_fk": { + "name": "room_bans_room_id_arcade_rooms_id_fk", + "tableFrom": "room_bans", + "columnsFrom": ["room_id"], + "tableTo": "arcade_rooms", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_stats": { + "name": "user_stats", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "games_played": { + "name": "games_played", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_wins": { + "name": "total_wins", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "favorite_game_type": { + "name": "favorite_game_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "best_time": { + "name": "best_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "highest_accuracy": { + "name": "highest_accuracy", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_users_id_fk": { + "name": "user_stats_user_id_users_id_fk", + "tableFrom": "user_stats", + "columnsFrom": ["user_id"], + "tableTo": "users", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guest_id": { + "name": "guest_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upgraded_at": { + "name": "upgraded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_guest_id_unique": { + "name": "users_guest_id_unique", + "columns": ["guest_id"], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index cba133fc..720077f4 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -302,6 +302,13 @@ "when": 1766406120000, "tag": "0042_classroom-system-indexes", "breakpoints": true + }, + { + "idx": 43, + "version": "6", + "when": 1766706763639, + "tag": "0043_add_session_pause_columns", + "breakpoints": true } ] } diff --git a/apps/web/src/app/practice/[studentId]/PracticeClient.tsx b/apps/web/src/app/practice/[studentId]/PracticeClient.tsx index ae1b185c..84f1c341 100644 --- a/apps/web/src/app/practice/[studentId]/PracticeClient.tsx +++ b/apps/web/src/app/practice/[studentId]/PracticeClient.tsx @@ -18,7 +18,11 @@ import { useEndSessionEarly, useRecordSlotResult, } from '@/hooks/useSessionPlan' -import { useSessionBroadcast, type ReceivedAbacusControl } from '@/hooks/useSessionBroadcast' +import { + useSessionBroadcast, + type ReceivedAbacusControl, + type TeacherPauseRequest, +} from '@/hooks/useSessionBroadcast' import { css } from '../../../../styled-system/css' interface PracticeClientProps { @@ -50,6 +54,11 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl const [browseIndex, setBrowseIndex] = useState(0) // Teacher abacus control - receives commands from observing teacher const [teacherControl, setTeacherControl] = useState(null) + // Teacher-initiated pause/resume requests from observing teacher + const [teacherPauseRequest, setTeacherPauseRequest] = useState(null) + const [teacherResumeRequest, setTeacherResumeRequest] = useState(false) + // Manual pause request from HUD + const [manualPauseRequest, setManualPauseRequest] = useState(false) // Session plan mutations const recordResult = useRecordSlotResult() @@ -76,9 +85,9 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl return { totalProblems: total, completedProblems: completed } }, [currentPlan.parts, currentPlan.currentPartIndex, currentPlan.currentSlotIndex]) - // Pause/resume handlers - just update HUD state (ActiveSession owns the modal) + // Pause handler - triggers manual pause in ActiveSession const handlePause = useCallback(() => { - setIsPaused(true) + setManualPauseRequest(true) }, []) const handleResume = useCallback(() => { @@ -125,8 +134,11 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl // Broadcast session state if student is in a classroom // 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), }) // Build session HUD data for PracticeSubNav @@ -212,7 +224,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl }} onAnswer={handleAnswer} onEndEarly={handleEndEarly} - onPause={handlePause} + onPause={() => setIsPaused(true)} onResume={handleResume} onComplete={handleSessionComplete} onTimingUpdate={setTimingData} @@ -222,6 +234,12 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl onBrowseIndexChange={setBrowseIndex} teacherControl={teacherControl} onTeacherControlHandled={() => setTeacherControl(null)} + teacherPauseRequest={teacherPauseRequest} + onTeacherPauseHandled={() => setTeacherPauseRequest(null)} + teacherResumeRequest={teacherResumeRequest} + onTeacherResumeHandled={() => setTeacherResumeRequest(false)} + manualPauseRequest={manualPauseRequest} + onManualPauseHandled={() => setManualPauseRequest(false)} /> diff --git a/apps/web/src/components/classroom/SessionObserverModal.tsx b/apps/web/src/components/classroom/SessionObserverModal.tsx index 35cd63be..cd554ec0 100644 --- a/apps/web/src/components/classroom/SessionObserverModal.tsx +++ b/apps/web/src/components/classroom/SessionObserverModal.tsx @@ -51,11 +51,15 @@ export function SessionObserverModal({ const { requestDock, dock, setDockedValue, isDockedByUser } = useMyAbacus() // Subscribe to the session's socket channel - const { state, isConnected, isObserving, error, sendControl } = useSessionObserver( - isOpen ? session.sessionId : undefined, - isOpen ? observerId : undefined, - isOpen - ) + const { state, isConnected, isObserving, error, sendControl, sendPause, sendResume } = + useSessionObserver( + isOpen ? session.sessionId : undefined, + isOpen ? observerId : undefined, + isOpen + ) + + // Track if we've paused the session (teacher controls resume) + const [hasPausedSession, setHasPausedSession] = useState(false) // Ref for measuring problem container height (same pattern as ActiveSession) const problemRef = useRef(null) @@ -98,6 +102,18 @@ export function SessionObserverModal({ sendControl({ type: 'show-abacus' }) }, [dock, isDockedByUser, requestDock, sendControl]) + // Pause the student's session + const handlePauseSession = useCallback(() => { + sendPause('Your teacher needs your attention.') + setHasPausedSession(true) + }, [sendPause]) + + // Resume the student's session + const handleResumeSession = useCallback(() => { + sendResume() + setHasPausedSession(false) + }, [sendResume]) + // Two-way sync: When student's abacus changes, sync teacher's docked abacus useEffect(() => { if (!isDockedByUser || !state?.studentAnswer) return @@ -372,29 +388,74 @@ export function SessionObserverModal({ - {/* Dock both abaci button */} - {state && state.phase === 'problem' && ( - - )} + {/* Teacher controls: pause/resume and dock abaci */} +
+ {/* Pause/Resume button */} + {isObserving && ( + + )} + + {/* Dock both abaci button */} + {state && state.phase === 'problem' && ( + + )} +
diff --git a/apps/web/src/components/practice/ActiveSession.stories.tsx b/apps/web/src/components/practice/ActiveSession.stories.tsx index 74ac9170..91c1e2f2 100644 --- a/apps/web/src/components/practice/ActiveSession.stories.tsx +++ b/apps/web/src/components/practice/ActiveSession.stories.tsx @@ -213,6 +213,10 @@ function createMockSessionPlanWithProblems(config: { approvedAt: new Date(Date.now() - 60000), startedAt: new Date(Date.now() - 30000), completedAt: null, + isPaused: false, + pausedAt: null, + pausedBy: null, + pauseReason: null, } } diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index 6382cd67..55fe48a6 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -120,6 +120,18 @@ interface ActiveSessionProps { teacherControl?: ReceivedAbacusControl | null /** Called after teacher control has been handled (to clear the state) */ onTeacherControlHandled?: () => void + /** Teacher-initiated pause request (from observing teacher) */ + teacherPauseRequest?: { message?: string } | null + /** Called after teacher pause has been handled (to clear the state) */ + onTeacherPauseHandled?: () => void + /** Teacher-initiated resume request (from observing teacher) */ + teacherResumeRequest?: boolean + /** Called after teacher resume has been handled (to clear the state) */ + onTeacherResumeHandled?: () => void + /** Manual pause request from parent (HUD pause button) */ + manualPauseRequest?: boolean + /** Called after manual pause has been handled (to clear the state) */ + onManualPauseHandled?: () => void } /** @@ -552,6 +564,12 @@ export function ActiveSession({ onBrowseIndexChange, teacherControl, onTeacherControlHandled, + teacherPauseRequest, + onTeacherPauseHandled, + teacherResumeRequest, + onTeacherResumeHandled, + manualPauseRequest, + onManualPauseHandled, }: ActiveSessionProps) { const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' @@ -781,6 +799,81 @@ export function ActiveSession({ onTeacherControlHandled, ]) + // Handle teacher-initiated pause requests + // Use a ref to track if we've handled this request to prevent duplicate handling + const handledPauseRef = useRef(false) + useEffect(() => { + if (!teacherPauseRequest) { + handledPauseRef.current = false + return + } + if (handledPauseRef.current) return + handledPauseRef.current = true + + // Pause the session with teacher reason + const newPauseInfo: PauseInfo = { + pausedAt: new Date(), + reason: 'teacher', + teacherMessage: teacherPauseRequest.message, + } + setPauseInfo(newPauseInfo) + pause() + onPause?.(newPauseInfo) + + console.log('[ActiveSession] Teacher paused session:', teacherPauseRequest.message) + + // Clear the request after handling + onTeacherPauseHandled?.() + }, [teacherPauseRequest, pause, onPause, onTeacherPauseHandled]) + + // Handle teacher-initiated resume requests + // Use a ref to track if we've handled this request to prevent duplicate handling + const handledResumeRef = useRef(false) + useEffect(() => { + if (!teacherResumeRequest) { + handledResumeRef.current = false + return + } + if (handledResumeRef.current) return + handledResumeRef.current = true + + // Resume the session + setPauseInfo(undefined) + lastResumeTimeRef.current = Date.now() + resume() + onResume?.() + + console.log('[ActiveSession] Teacher resumed session') + + // Clear the request after handling + onTeacherResumeHandled?.() + }, [teacherResumeRequest, resume, onResume, onTeacherResumeHandled]) + + // Handle manual pause requests from parent (HUD pause button) + const handledManualPauseRef = useRef(false) + useEffect(() => { + if (!manualPauseRequest) { + handledManualPauseRef.current = false + return + } + if (handledManualPauseRef.current) return + handledManualPauseRef.current = true + + // Pause the session with manual reason + const newPauseInfo: PauseInfo = { + pausedAt: new Date(), + reason: 'manual', + } + setPauseInfo(newPauseInfo) + pause() + onPause?.(newPauseInfo) + + console.log('[ActiveSession] Manual pause triggered from HUD') + + // Clear the request after handling + onManualPauseHandled?.() + }, [manualPauseRequest, pause, onPause, onManualPauseHandled]) + // Track which help elements have been individually dismissed // These reset when entering a new help session (helpContext changes) const [helpAbacusDismissed, setHelpAbacusDismissed] = useState(false) diff --git a/apps/web/src/components/practice/SessionPausedModal.stories.tsx b/apps/web/src/components/practice/SessionPausedModal.stories.tsx index f11ca32a..e4fac34e 100644 --- a/apps/web/src/components/practice/SessionPausedModal.stories.tsx +++ b/apps/web/src/components/practice/SessionPausedModal.stories.tsx @@ -132,6 +132,10 @@ function createMockSessionPlan(config: { startedAt: new Date(Date.now() - 10 * 60 * 1000), completedAt: null, masteredSkillIds: [], + isPaused: false, + pausedAt: null, + pausedBy: null, + pauseReason: null, } } diff --git a/apps/web/src/components/practice/SessionPausedModal.tsx b/apps/web/src/components/practice/SessionPausedModal.tsx index 693415c6..7c67d504 100644 --- a/apps/web/src/components/practice/SessionPausedModal.tsx +++ b/apps/web/src/components/practice/SessionPausedModal.tsx @@ -52,6 +52,14 @@ const MANUAL_PAUSE_PHRASES = [ 'Smart break!', ] +// Phrases for teacher-initiated pause +const TEACHER_PAUSE_PHRASES = [ + 'Teacher called timeout!', + 'Hold on a moment!', + 'Quick pause!', + 'Wait for your teacher!', +] + // Intl formatters for duration display const secondsFormatter = new Intl.NumberFormat('en', { style: 'unit', @@ -146,7 +154,17 @@ export function SessionPausedModal({ // Pick a random phrase once per pause (stable while modal is open) const pausePhrase = useMemo(() => { - const phrases = pauseInfo?.reason === 'auto-timeout' ? AUTO_PAUSE_PHRASES : MANUAL_PAUSE_PHRASES + let phrases: string[] + switch (pauseInfo?.reason) { + case 'auto-timeout': + phrases = AUTO_PAUSE_PHRASES + break + case 'teacher': + phrases = TEACHER_PAUSE_PHRASES + break + default: + phrases = MANUAL_PAUSE_PHRASES + } return phrases[Math.floor(Math.random() * phrases.length)] // eslint-disable-next-line react-hooks/exhaustive-deps }, [pauseInfo?.pausedAt?.getTime(), pauseInfo?.reason]) @@ -180,6 +198,7 @@ export function SessionPausedModal({ // Determine greeting based on pause reason const isAutoTimeout = pauseInfo?.reason === 'auto-timeout' + const isTeacherPause = pauseInfo?.reason === 'teacher' const stats = pauseInfo?.autoPauseStats return ( @@ -256,7 +275,7 @@ export function SessionPausedModal({ gap: '0.375rem', })} > - {isAutoTimeout ? '🤔' : '☕'} + {isTeacherPause ? '👩‍🏫' : isAutoTimeout ? '🤔' : '☕'} {pausePhrase} {pauseInfo && ( @@ -386,6 +405,33 @@ export function SessionPausedModal({ )} + {/* Teacher pause message - shown when teacher pauses the session */} + {isTeacherPause && ( +
+

+ {pauseInfo?.teacherMessage || + 'Your teacher paused the session. Please wait for them to resume.'} +

+
+ )} + {/* Progress summary - celebratory */}
- ▶️ Keep Going! + {isTeacherPause ? '⏳ Waiting for teacher...' : '▶️ Keep Going!'}