diff --git a/apps/web/drizzle/0028_medical_wolfsbane.sql b/apps/web/drizzle/0028_medical_wolfsbane.sql new file mode 100644 index 00000000..fe1bf0d9 --- /dev/null +++ b/apps/web/drizzle/0028_medical_wolfsbane.sql @@ -0,0 +1,3 @@ +-- Custom SQL migration file, put your code below! -- +-- Add mastered_skill_ids column to session_plans for skill mismatch detection +ALTER TABLE `session_plans` ADD `mastered_skill_ids` text DEFAULT '[]' NOT NULL; diff --git a/apps/web/drizzle/meta/0028_snapshot.json b/apps/web/drizzle/meta/0028_snapshot.json new file mode 100644 index 00000000..361a3ac1 --- /dev/null +++ b/apps/web/drizzle/meta/0028_snapshot.json @@ -0,0 +1,1094 @@ +{ + "id": "8591165f-3968-46a5-bb73-36c10744c85f", + "prevId": "093efdd3-6188-419b-830f-f44125deee4b", + "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": {} + } +} \ No newline at end of file diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index 987caa97..04af3c33 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1765055035935, "tag": "0027_help_system_schema", "breakpoints": true + }, + { + "idx": 28, + "version": "6", + "when": 1765331044112, + "tag": "0028_medical_wolfsbane", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx index afd5aabc..8edc768e 100644 --- a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx +++ b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation' import { useCallback, useState } from 'react' import { PageWithNav } from '@/components/PageWithNav' import { + type ActiveSessionState, type CurrentPhaseInfo, ProgressDashboard, type SkillProgress, @@ -19,6 +20,7 @@ import type { PlayerCurriculum } from '@/db/schema/player-curriculum' 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 { css } from '../../../../../styled-system/css' interface DashboardClientProps { @@ -27,6 +29,8 @@ interface DashboardClientProps { curriculum: PlayerCurriculum | null skills: PlayerSkillMastery[] recentSessions: PracticeSession[] + activeSession: SessionPlan | null + currentMasteredSkillIds: string[] } // Mock curriculum phase data (until we integrate with actual curriculum) @@ -83,14 +87,18 @@ function formatSkillName(skillId: string): string { * Dashboard Client Component * * Shows the student's progress dashboard. - * "Continue Practice" navigates to /configure to set up a new session. + * "Start Practice" navigates to /configure to set up a new session. + * "Resume Practice" continues an existing active session. */ + export function DashboardClient({ studentId, player, curriculum, skills, recentSessions, + activeSession, + currentMasteredSkillIds, }: DashboardClientProps) { const router = useRouter() const { resolvedTheme } = useTheme() @@ -99,6 +107,28 @@ export function DashboardClient({ // Modal states for onboarding features const [showManualSkillModal, setShowManualSkillModal] = useState(false) const [showOfflineSessionModal, setShowOfflineSessionModal] = useState(false) + const [isStartingOver, setIsStartingOver] = useState(false) + + // Build ActiveSessionState for ProgressDashboard + const activeSessionState: ActiveSessionState | null = activeSession + ? (() => { + const sessionSkillIds = activeSession.masteredSkillIds || [] + const sessionSet = new Set(sessionSkillIds) + const currentSet = new Set(currentMasteredSkillIds) + const skillsAdded = currentMasteredSkillIds.filter((id) => !sessionSet.has(id)).length + const skillsRemoved = sessionSkillIds.filter((id) => !currentSet.has(id)).length + + return { + id: activeSession.id, + status: activeSession.status as 'draft' | 'approved' | 'in_progress', + completedCount: activeSession.results.length, + totalCount: activeSession.summary.totalProblemCount, + hasSkillMismatch: skillsAdded > 0 || skillsRemoved > 0, + skillsAdded, + skillsRemoved, + } + })() + : null // Build the student object const selectedStudent: StudentWithProgress = { @@ -131,8 +161,8 @@ export function DashboardClient({ consecutiveCorrect: s.consecutiveCorrect, })) - // Handle continue practice - navigate to configuration page - const handleContinuePractice = useCallback(() => { + // Handle start practice - navigate to configuration page + const handleStartPractice = useCallback(() => { router.push(`/practice/${studentId}/configure`, { scroll: false }) }, [studentId, router]) @@ -193,6 +223,30 @@ export function DashboardClient({ [] ) + // Handle starting over (abandon current session and create new one) + const handleStartOver = useCallback(async () => { + if (!activeSession) return + setIsStartingOver(true) + try { + // First abandon the old session + await fetch(`/api/curriculum/${studentId}/sessions/plans/${activeSession.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'abandon' }), + }) + // Navigate to configure to create a new one + router.push(`/practice/${studentId}/configure`) + } catch (error) { + console.error('Failed to start over:', error) + setIsStartingOver(false) + } + }, [activeSession, studentId, router]) + + // Handle resuming the current session + const handleResumeSession = useCallback(() => { + router.push(`/practice/${studentId}/session`) + }, [studentId, router]) + return (
- {/* Progress Dashboard */} + {/* Progress Dashboard - unified session-aware component */} s.masteryLevel === 'mastered') + .map((s) => s.skillId) + return ( ) } diff --git a/apps/web/src/components/practice/ProgressDashboard.stories.tsx b/apps/web/src/components/practice/ProgressDashboard.stories.tsx index fedd8195..300cb8eb 100644 --- a/apps/web/src/components/practice/ProgressDashboard.stories.tsx +++ b/apps/web/src/components/practice/ProgressDashboard.stories.tsx @@ -97,7 +97,7 @@ const sampleRecentSkills: SkillProgress[] = [ ] const handlers = { - onContinuePractice: () => alert('Continue Practice clicked!'), + onStartPractice: () => alert('Start Practice clicked!'), onViewFullProgress: () => alert('View Full Progress clicked!'), onGenerateWorksheet: () => alert('Generate Worksheet clicked!'), onChangeStudent: () => alert('Change Student clicked!'), @@ -279,3 +279,57 @@ export const WithFocusAreas: Story = { ), } + +/** + * With Active Session - shows resume button instead of start + */ +export const WithActiveSession: Story = { + render: () => ( + + alert('Resume Practice clicked!')} + onStartOver={() => alert('Start over clicked!')} + {...handlers} + /> + + ), +} + +/** + * With Active Session and Skill Mismatch - shows warning + */ +export const WithActiveSessionMismatch: Story = { + render: () => ( + + alert('Resume Practice clicked!')} + onStartOver={() => alert('Start over clicked!')} + {...handlers} + /> + + ), +} diff --git a/apps/web/src/components/practice/ProgressDashboard.tsx b/apps/web/src/components/practice/ProgressDashboard.tsx index 4b25a00c..19b93411 100644 --- a/apps/web/src/components/practice/ProgressDashboard.tsx +++ b/apps/web/src/components/practice/ProgressDashboard.tsx @@ -36,13 +36,42 @@ export interface CurrentPhaseInfo { totalSkills: number } +/** + * Active session state for unified display + */ +export interface ActiveSessionState { + /** Session ID */ + id: string + /** Current status */ + status: 'draft' | 'approved' | 'in_progress' + /** Problems completed so far */ + completedCount: number + /** Total problems in session */ + totalCount: number + /** Whether skills have changed since session was created */ + hasSkillMismatch: boolean + /** Number of skills added since session creation */ + skillsAdded: number + /** Number of skills removed since session creation */ + skillsRemoved: number +} + interface ProgressDashboardProps { student: StudentWithProgress currentPhase: CurrentPhaseInfo recentSkills?: SkillProgress[] /** Skills that need extra practice (used heavy help recently) */ focusAreas?: SkillProgress[] - onContinuePractice: () => void + /** Active session state (if any) */ + activeSession?: ActiveSessionState | null + /** Callback when no active session - start new practice */ + onStartPractice: () => void + /** Callback when active session - resume it */ + onResumePractice?: () => void + /** Callback to start over (abandon old session, start fresh) */ + onStartOver?: () => void + /** Loading state for start over action */ + isStartingOver?: boolean onViewFullProgress: () => void onGenerateWorksheet: () => void /** Callback to run placement test */ @@ -90,7 +119,11 @@ export function ProgressDashboard({ currentPhase, recentSkills = [], focusAreas = [], - onContinuePractice, + activeSession, + onStartPractice, + onResumePractice, + onStartOver, + isStartingOver = false, onViewFullProgress, onGenerateWorksheet, onRunPlacementTest, @@ -107,6 +140,9 @@ export function ProgressDashboard({ ? Math.round((currentPhase.masteredSkills / currentPhase.totalSkills) * 100) : 0 + // Determine if we have an active session + const hasActiveSession = !!activeSession + return (
{currentPhase.description}

+ + {/* Skill mismatch warning - inline in level card */} + {hasActiveSession && activeSession.hasSkillMismatch && ( +
+

+ Skills changed since session was created + {activeSession.skillsAdded > 0 && ` (+${activeSession.skillsAdded} new)`} + {activeSession.skillsRemoved > 0 && ` (-${activeSession.skillsRemoved} removed)`} +

+
+ )}
- {/* Action buttons */} + {/* Primary action - session-aware */}
+ {hasActiveSession ? ( + <> + {/* Resume button with progress indicator */} + + {/* Session progress info */} +

+ {activeSession.completedCount} of {activeSession.totalCount} problems done +

+ {/* Secondary session action */} + + + ) : ( + /* Start new practice button */ + + )} +
+ + {/* Secondary action buttons */} +
-
- - - -
+ Worksheet +
{/* Focus Areas - Skills needing extra practice */} diff --git a/apps/web/src/components/practice/index.ts b/apps/web/src/components/practice/index.ts index 8f579a1b..5f069bd8 100644 --- a/apps/web/src/components/practice/index.ts +++ b/apps/web/src/components/practice/index.ts @@ -14,7 +14,7 @@ export { ContinueSessionCard } from './ContinueSessionCard' export { useHasPhysicalKeyboard, useIsTouchDevice } from './hooks/useDeviceDetection' export { NumericKeypad } from './NumericKeypad' export { PracticeErrorBoundary } from './PracticeErrorBoundary' -export type { CurrentPhaseInfo, SkillProgress } from './ProgressDashboard' +export type { ActiveSessionState, CurrentPhaseInfo, SkillProgress } from './ProgressDashboard' export { ProgressDashboard } from './ProgressDashboard' export { SessionSummary } from './SessionSummary' export type { StudentWithProgress } from './StudentSelector' diff --git a/apps/web/src/db/schema/session-plans.ts b/apps/web/src/db/schema/session-plans.ts index 8cbb74a6..91ac61d8 100644 --- a/apps/web/src/db/schema/session-plans.ts +++ b/apps/web/src/db/schema/session-plans.ts @@ -206,6 +206,12 @@ export const sessionPlans = sqliteTable( /** Human-readable summary */ summary: text('summary', { mode: 'json' }).notNull().$type(), + /** Skill IDs that were mastered when this session was generated (for mismatch detection) */ + masteredSkillIds: text('mastered_skill_ids', { mode: 'json' }) + .notNull() + .default('[]') + .$type(), + // ---- Session State ---- /** Current status */ diff --git a/apps/web/src/lib/curriculum/session-planner.ts b/apps/web/src/lib/curriculum/session-planner.ts index d2e3bd28..a4d84077 100644 --- a/apps/web/src/lib/curriculum/session-planner.ts +++ b/apps/web/src/lib/curriculum/session-planner.ts @@ -14,7 +14,7 @@ */ import { createId } from '@paralleldrive/cuid2' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import { db, schema } from '@/db' import type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery' import { @@ -164,6 +164,7 @@ export async function generateSessionPlan( avgTimePerProblemSeconds: avgTimeSeconds, parts, summary, + masteredSkillIds: masteredSkills.map((s) => s.skillId), status: 'draft', currentPartIndex: 0, currentSlotIndex: 0, @@ -291,10 +292,13 @@ function sessionHasPreGeneratedProblems(plan: SessionPlan): boolean { export async function getActiveSessionPlan(playerId: string): Promise { // Find any session that's not completed or abandoned // This includes: draft, approved, in_progress + // IMPORTANT: Also check completedAt IS NULL to handle inconsistent data + // where status may be in_progress but completedAt is set const result = await db.query.sessionPlans.findFirst({ where: and( eq(schema.sessionPlans.playerId, playerId), - inArray(schema.sessionPlans.status, ['draft', 'approved', 'in_progress']) + inArray(schema.sessionPlans.status, ['draft', 'approved', 'in_progress']), + isNull(schema.sessionPlans.completedAt) ), orderBy: (plans, { desc }) => [desc(plans.createdAt)], })