diff --git a/apps/web/drizzle/0029_first_black_tarantula.sql b/apps/web/drizzle/0029_first_black_tarantula.sql new file mode 100644 index 00000000..2238c170 --- /dev/null +++ b/apps/web/drizzle/0029_first_black_tarantula.sql @@ -0,0 +1,6 @@ +-- Custom SQL migration file, put your code below! -- +-- Add response time tracking columns to player_skill_mastery table + +ALTER TABLE `player_skill_mastery` ADD `total_response_time_ms` integer DEFAULT 0 NOT NULL; +--> statement-breakpoint +ALTER TABLE `player_skill_mastery` ADD `response_time_count` integer DEFAULT 0 NOT NULL; diff --git a/apps/web/drizzle/meta/0029_snapshot.json b/apps/web/drizzle/meta/0029_snapshot.json new file mode 100644 index 00000000..1778287d --- /dev/null +++ b/apps/web/drizzle/meta/0029_snapshot.json @@ -0,0 +1,1038 @@ +{ + "id": "c620580f-4b51-4b1b-b49d-e7c739f6076f", + "prevId": "8591165f-3968-46a5-bb73-36c10744c85f", + "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 d495fd28..48c7d89e 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1765331044112, "tag": "0028_medical_wolfsbane", "breakpoints": true + }, + { + "idx": 29, + "version": "6", + "when": 1765496987070, + "tag": "0029_first_black_tarantula", + "breakpoints": true } ] } diff --git a/apps/web/src/app/api/abacus-settings/route.ts b/apps/web/src/app/api/abacus-settings/route.ts index 11e1c09d..d95fb32c 100644 --- a/apps/web/src/app/api/abacus-settings/route.ts +++ b/apps/web/src/app/api/abacus-settings/route.ts @@ -41,7 +41,14 @@ export async function GET() { export async function PATCH(req: NextRequest) { try { const viewerId = await getViewerId() - const body = await req.json() + + // Handle empty or invalid JSON body gracefully + let body: Record + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid or empty request body' }, { status: 400 }) + } // Security: Strip userId from request body - it must come from session only const { userId: _, ...updates } = body diff --git a/apps/web/src/app/api/curriculum/[playerId]/skills/performance/route.ts b/apps/web/src/app/api/curriculum/[playerId]/skills/performance/route.ts new file mode 100644 index 00000000..06a555cf --- /dev/null +++ b/apps/web/src/app/api/curriculum/[playerId]/skills/performance/route.ts @@ -0,0 +1,22 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { analyzeSkillPerformance } from '@/lib/curriculum/progress-manager' + +interface RouteParams { + params: Promise<{ playerId: string }> +} + +/** + * GET /api/curriculum/[playerId]/skills/performance + * Get skill performance analysis for a player (response times, strengths/weaknesses) + */ +export async function GET(_request: NextRequest, { params }: RouteParams) { + const { playerId } = await params + + try { + const analysis = await analyzeSkillPerformance(playerId) + return NextResponse.json({ analysis }) + } catch (error) { + console.error('Error fetching skill performance:', error) + return NextResponse.json({ error: 'Failed to fetch skill performance' }, { status: 500 }) + } +} diff --git a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx index 20f9eb12..16643d58 100644 --- a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx +++ b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx @@ -8,6 +8,7 @@ import { type CurrentPhaseInfo, PracticeSubNav, ProgressDashboard, + SkillPerformanceReports, type SkillProgress, StartPracticeModal, type StudentWithProgress, @@ -301,6 +302,11 @@ export function DashboardClient({ onSetSkillsManually={handleSetSkillsManually} onRecordOfflinePractice={handleRecordOfflinePractice} /> + + {/* Skill Performance Reports - shows response time analysis */} +
+ +
{/* Manual Skill Selector Modal */} diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index 55ee4a44..102a222b 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -93,6 +93,7 @@ import { useInteractionPhase } from './hooks/useInteractionPhase' import { usePracticeSoundEffects } from './hooks/usePracticeSoundEffects' import { NumericKeypad } from './NumericKeypad' import { PracticeHelpOverlay } from './PracticeHelpOverlay' +import { PracticeTimingDisplay } from './PracticeTimingDisplay' import { ProblemDebugPanel } from './ProblemDebugPanel' import { VerticalProblem } from './VerticalProblem' @@ -586,7 +587,8 @@ export function ActiveSession({ // Transition to submitting phase startSubmit() - const responseTimeMs = Date.now() - attemptData.startTime + // Subtract accumulated pause time to get actual response time + const responseTimeMs = Date.now() - attemptData.startTime - attemptData.accumulatedPauseMs const isCorrect = answerNum === attemptData.problem.answer // Record the result @@ -715,8 +717,8 @@ export function ActiveSession({ // Calculate the threshold and stats from historical results const { threshold, stats } = calculateAutoPauseInfo(plan.results) - // Calculate remaining time until auto-pause - const elapsedMs = Date.now() - attempt.startTime + // Calculate remaining time until auto-pause (using actual working time, not total elapsed) + const elapsedMs = Date.now() - attempt.startTime - attempt.accumulatedPauseMs const remainingMs = threshold - elapsedMs // Create pause info for auto-timeout @@ -742,7 +744,15 @@ export function ActiveSession({ }, remainingMs) return () => clearTimeout(timeoutId) - }, [phase.phase, isPaused, attempt?.startTime, plan.results, pause, onPause]) + }, [ + phase.phase, + isPaused, + attempt?.startTime, + attempt?.accumulatedPauseMs, + plan.results, + pause, + onPause, + ]) const handlePause = useCallback(() => { const pauseInfo: PauseInfo = { @@ -1029,6 +1039,20 @@ export function ActiveSession({ )} + {/* Timing Display - shows current problem timer, average, and per-part-type breakdown */} + {/* Always shown regardless of hideHud - timing info is always useful */} + {attempt && ( + + )} + {/* Problem display */}
0) { + return `${minutes}:${seconds.toString().padStart(2, '0')}` + } + return `${seconds}s` +} + +/** + * Format milliseconds for display as decimal seconds + */ +function formatSecondsDecimal(ms: number): string { + return `${(ms / 1000).toFixed(1)}s` +} + +/** + * Calculate mean and standard deviation of response times + */ +function calculateStats(times: number[]): { + mean: number + stdDev: number + count: number +} { + if (times.length === 0) { + return { mean: 0, stdDev: 0, count: 0 } + } + + const count = times.length + const mean = times.reduce((sum, t) => sum + t, 0) / count + + if (count < 2) { + return { mean, stdDev: 0, count } + } + + const squaredDiffs = times.map((t) => (t - mean) ** 2) + const variance = squaredDiffs.reduce((sum, d) => sum + d, 0) / (count - 1) + const stdDev = Math.sqrt(variance) + + return { mean, stdDev, count } +} + +function getPartTypeLabel(type: 'abacus' | 'visualization' | 'linear'): string { + switch (type) { + case 'abacus': + return 'Abacus' + case 'visualization': + return 'Visualize' + case 'linear': + return 'Linear' + } +} + +function getPartTypeEmoji(type: 'abacus' | 'visualization' | 'linear'): string { + switch (type) { + case 'abacus': + return '🧮' + case 'visualization': + return '🧠' + case 'linear': + return '💭' + } +} + +/** + * PracticeTimingDisplay - Shows timing stats during practice + * + * Displays: + * - Live timer for current problem + * - Average time per problem with SpeedMeter visualization + * - Breakdown by part type (abacus/visualization/linear) + */ +export function PracticeTimingDisplay({ + results, + parts, + attemptStartTime, + accumulatedPauseMs, + isPaused, + currentPartType, + isDark, +}: PracticeTimingDisplayProps) { + // Live-updating current problem timer + const [currentElapsedMs, setCurrentElapsedMs] = useState(0) + + // Update current timer every 100ms + useEffect(() => { + if (isPaused) return + + const updateTimer = () => { + const elapsed = Date.now() - attemptStartTime - accumulatedPauseMs + setCurrentElapsedMs(Math.max(0, elapsed)) + } + + updateTimer() + const interval = setInterval(updateTimer, 100) + return () => clearInterval(interval) + }, [attemptStartTime, accumulatedPauseMs, isPaused]) + + // Calculate overall stats + const allTimes = results.map((r) => r.responseTimeMs) + const overallStats = calculateStats(allTimes) + const hasEnoughData = overallStats.count >= MIN_SAMPLES_FOR_STATS + + // Calculate per-part-type stats + const partTypeStats = (['abacus', 'visualization', 'linear'] as const).map((type) => { + const typeTimes = results + .filter((r) => { + const part = parts[r.partNumber - 1] + return part && part.type === type + }) + .map((r) => r.responseTimeMs) + return { + type, + ...calculateStats(typeTimes), + } + }) + + // Filter to only part types with data + const activePartTypes = partTypeStats.filter((s) => s.count > 0) + + // Calculate threshold for SpeedMeter (mean + 2 stddev, clamped) + const threshold = hasEnoughData + ? Math.max(30_000, Math.min(overallStats.mean + 2 * overallStats.stdDev, 5 * 60 * 1000)) + : 60_000 // Default 1 minute if not enough data + + return ( +
+ {/* Current problem timer - prominent display */} +
+
+ {getPartTypeEmoji(currentPartType)} + + This problem + +
+ threshold + ? isDark + ? 'red.400' + : 'red.500' + : currentElapsedMs > overallStats.mean + overallStats.stdDev + ? isDark + ? 'yellow.400' + : 'yellow.600' + : isDark + ? 'green.400' + : 'green.600', + })} + > + {formatTime(currentElapsedMs)} + +
+ + {/* Speed meter - shows where current time falls relative to average */} + {hasEnoughData && ( +
+ +
+ )} + + {/* Per-part-type breakdown */} + {activePartTypes.length > 1 && ( +
+ {activePartTypes.map((stats) => ( +
+ {getPartTypeEmoji(stats.type)} + + {getPartTypeLabel(stats.type)} + + + {formatSecondsDecimal(stats.mean)} + + + ({stats.count}) + +
+ ))} +
+ )} + + {/* Simple average display when not enough data for full visualization */} + {!hasEnoughData && overallStats.count > 0 && ( +
+ Avg so far: + + {formatSecondsDecimal(overallStats.mean)} + + ({overallStats.count} problems) +
+ )} +
+ ) +} diff --git a/apps/web/src/components/practice/SessionPausedModal.tsx b/apps/web/src/components/practice/SessionPausedModal.tsx index 01c8fb9b..4960820b 100644 --- a/apps/web/src/components/practice/SessionPausedModal.tsx +++ b/apps/web/src/components/practice/SessionPausedModal.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react' import { useTheme } from '@/contexts/ThemeContext' import type { SessionPart, SessionPlan } from '@/db/schema/session-plans' import { css } from '../../../styled-system/css' +import { SpeedMeter } from './SpeedMeter' /** * Statistics about response times used for auto-pause threshold @@ -125,122 +126,6 @@ function formatSecondsFriendly(ms: number): string { return `about ${secondsFormatter.format(Math.round(seconds))}` } -/** - * Speed visualization - shows average speed vs variation - */ -function SpeedMeter({ - meanMs, - stdDevMs, - thresholdMs, - isDark, -}: { - meanMs: number - stdDevMs: number - thresholdMs: number - isDark: boolean -}) { - // Scale so the mean is around 50% and threshold is at 100% - // This ensures the visualization is always meaningful regardless of absolute values - const scaleMax = thresholdMs - const meanPercent = Math.min(95, Math.max(5, (meanMs / scaleMax) * 100)) - - // Variation should be visible but proportional - minimum 8% width for visibility - const rawVariationPercent = (stdDevMs / scaleMax) * 100 - const variationPercent = Math.max(8, Math.min(40, rawVariationPercent)) - - return ( -
- {/* Speed bar container */} -
- {/* Variation range (the "wiggle room") */} -
- - {/* Average marker */} -
- - {/* Threshold marker */} -
-
- - {/* Labels */} -
- Fast - - Your usual speed - - Pause -
-
- ) -} - export interface SessionPausedModalProps { /** Whether the modal is visible */ isOpen: boolean diff --git a/apps/web/src/components/practice/SkillPerformanceReports.tsx b/apps/web/src/components/practice/SkillPerformanceReports.tsx new file mode 100644 index 00000000..9b7bc214 --- /dev/null +++ b/apps/web/src/components/practice/SkillPerformanceReports.tsx @@ -0,0 +1,449 @@ +'use client' + +import { useEffect, useState } from 'react' +import { css } from '../../../styled-system/css' + +interface SkillPerformance { + skillId: string + masteryLevel: 'learning' | 'practicing' | 'mastered' + attempts: number + accuracy: number + avgResponseTimeMs: number | null + responseTimeCount: number +} + +interface SkillPerformanceAnalysis { + skills: SkillPerformance[] + overallAvgResponseTimeMs: number | null + fastSkills: SkillPerformance[] + slowSkills: SkillPerformance[] + lowAccuracySkills: SkillPerformance[] + reinforcementSkills: SkillPerformance[] +} + +interface SkillPerformanceReportsProps { + playerId: string + isDark?: boolean +} + +// Format skill ID to human-readable name +function formatSkillName(skillId: string): string { + // Examples: + // "basic.directAddition" -> "Direct Addition" + // "fiveComplements.4=5-1" -> "5s: 4=5-1" + // "tenComplements.9=10-1" -> "10s: 9=10-1" + + if (skillId.startsWith('basic.')) { + const skill = skillId.replace('basic.', '') + if (skill === 'directAddition') return 'Direct Addition' + if (skill === 'heavenBead') return 'Heaven Bead' + if (skill === 'simpleCombinations') return 'Simple Combos' + if (skill === 'directSubtraction') return 'Direct Subtraction' + if (skill === 'heavenBeadSubtraction') return 'Heaven Bead Sub' + if (skill === 'simpleCombinationsSub') return 'Simple Combos Sub' + return skill + } + if (skillId.startsWith('fiveComplements.')) { + return `5s: ${skillId.replace('fiveComplements.', '')}` + } + if (skillId.startsWith('tenComplements.')) { + return `10s: ${skillId.replace('tenComplements.', '')}` + } + if (skillId.startsWith('fiveComplementsSub.')) { + return `5s Sub: ${skillId.replace('fiveComplementsSub.', '')}` + } + if (skillId.startsWith('tenComplementsSub.')) { + return `10s Sub: ${skillId.replace('tenComplementsSub.', '')}` + } + return skillId +} + +// Format milliseconds to readable duration +function formatTime(ms: number): string { + if (ms < 1000) return `${ms}ms` + const seconds = ms / 1000 + if (seconds < 60) return `${seconds.toFixed(1)}s` + const minutes = Math.floor(seconds / 60) + const remainingSeconds = Math.round(seconds % 60) + return `${minutes}m ${remainingSeconds}s` +} + +// Get mastery level badge style +function getMasteryBadgeStyle(level: string, isDark: boolean) { + const baseStyle = { + display: 'inline-block', + padding: '2px 8px', + borderRadius: '12px', + fontSize: '0.75rem', + fontWeight: 'bold', + } + + switch (level) { + case 'mastered': + return { + ...baseStyle, + backgroundColor: isDark ? 'green.800' : 'green.100', + color: isDark ? 'green.200' : 'green.800', + } + case 'practicing': + return { + ...baseStyle, + backgroundColor: isDark ? 'yellow.800' : 'yellow.100', + color: isDark ? 'yellow.200' : 'yellow.800', + } + default: + return { + ...baseStyle, + backgroundColor: isDark ? 'gray.700' : 'gray.200', + color: isDark ? 'gray.300' : 'gray.700', + } + } +} + +function SkillCard({ + skill, + isDark, + overallAvgMs, +}: { + skill: SkillPerformance + isDark: boolean + overallAvgMs: number | null +}) { + const speedIndicator = (() => { + if (!skill.avgResponseTimeMs || !overallAvgMs) return null + const ratio = skill.avgResponseTimeMs / overallAvgMs + if (ratio < 0.7) return { emoji: '🚀', label: 'Fast', color: 'green' } + if (ratio > 1.3) return { emoji: '🐢', label: 'Slow', color: 'orange' } + return { emoji: '➡️', label: 'Average', color: 'gray' } + })() + + return ( +
+
+ + {formatSkillName(skill.skillId)} + + + {skill.masteryLevel} + +
+ +
+
+ Accuracy: + = 0.7 + ? isDark + ? 'green.400' + : 'green.600' + : isDark + ? 'orange.400' + : 'orange.600', + fontWeight: 'medium', + })} + > + {Math.round(skill.accuracy * 100)}% + +
+
+ Attempts: + {skill.attempts} +
+ {skill.avgResponseTimeMs && ( +
+ Avg Time: + + {formatTime(skill.avgResponseTimeMs)} + + {speedIndicator && ( + + {speedIndicator.emoji} + + )} +
+ )} +
+
+ ) +} + +export function SkillPerformanceReports({ + playerId, + isDark = false, +}: SkillPerformanceReportsProps) { + const [analysis, setAnalysis] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + async function fetchPerformance() { + try { + const response = await fetch(`/api/curriculum/${playerId}/skills/performance`) + if (!response.ok) throw new Error('Failed to fetch') + const data = await response.json() + setAnalysis(data.analysis) + } catch (err) { + setError('Failed to load performance data') + console.error('Error fetching skill performance:', err) + } finally { + setLoading(false) + } + } + fetchPerformance() + }, [playerId]) + + if (loading) { + return ( +
+
+ Loading performance data... +
+
+ ) + } + + if (error || !analysis) { + return ( +
+
+ {error || 'No performance data available'} +
+
+ ) + } + + const hasTimingData = analysis.skills.some((s) => s.responseTimeCount > 0) + const hasSlowSkills = analysis.slowSkills.length > 0 + const hasLowAccuracySkills = analysis.lowAccuracySkills.length > 0 + const hasReinforcementSkills = analysis.reinforcementSkills.length > 0 + + // No data yet + if (analysis.skills.length === 0) { + return ( +
+

+ 📊 Skill Performance +

+

+ No practice data yet. Complete some practice sessions to see performance insights. +

+
+ ) + } + + return ( +
+

+ 📊 Skill Performance Reports +

+ + {/* Overall Stats */} + {hasTimingData && analysis.overallAvgResponseTimeMs && ( +
+
+ Average Response Time +
+
+ {formatTime(analysis.overallAvgResponseTimeMs)} +
+
+ )} + + {/* Areas Needing Work */} + {(hasSlowSkills || hasLowAccuracySkills || hasReinforcementSkills) && ( +
+

+ ⚠️ Areas Needing Work +

+
+ {analysis.slowSkills.map((skill) => ( + + ))} + {analysis.lowAccuracySkills + .filter((s) => !analysis.slowSkills.some((slow) => slow.skillId === s.skillId)) + .map((skill) => ( + + ))} + {analysis.reinforcementSkills + .filter( + (s) => + !analysis.slowSkills.some((slow) => slow.skillId === s.skillId) && + !analysis.lowAccuracySkills.some((low) => low.skillId === s.skillId) + ) + .map((skill) => ( + + ))} +
+
+ )} + + {/* Strengths */} + {analysis.fastSkills.length > 0 && ( +
+

+ 🌟 Strengths +

+
+ {analysis.fastSkills.map((skill) => ( + + ))} +
+
+ )} + + {/* All Skills Summary */} +
+

+ All Skills ({analysis.skills.length}) +

+
+ {analysis.skills.map((skill) => ( + + ))} +
+
+
+ ) +} diff --git a/apps/web/src/components/practice/SpeedMeter.tsx b/apps/web/src/components/practice/SpeedMeter.tsx new file mode 100644 index 00000000..36b94c3c --- /dev/null +++ b/apps/web/src/components/practice/SpeedMeter.tsx @@ -0,0 +1,183 @@ +'use client' + +import { css } from '../../../styled-system/css' + +export interface SpeedMeterProps { + /** Mean response time in milliseconds */ + meanMs: number + /** Standard deviation of response times in milliseconds */ + stdDevMs: number + /** Threshold for pause/slow indicator in milliseconds */ + thresholdMs: number + /** Whether dark mode is enabled */ + isDark: boolean + /** Optional current time to show as an indicator on the bar */ + currentTimeMs?: number + /** Optional compact mode for inline display */ + compact?: boolean + /** Label for the average marker (default: "Your usual speed") */ + averageLabel?: string + /** Label for the fast end (default: "Fast") */ + fastLabel?: string + /** Label for the slow/threshold end (default: "Pause") */ + slowLabel?: string +} + +/** + * Speed visualization bar - shows average speed vs variation + * Used in session pause modal and timing displays to visualize response time patterns + */ +export function SpeedMeter({ + meanMs, + stdDevMs, + thresholdMs, + isDark, + currentTimeMs, + compact = false, + averageLabel = 'Your usual speed', + fastLabel = 'Fast', + slowLabel = 'Pause', +}: SpeedMeterProps) { + // Scale so the mean is around 50% and threshold is at 100% + // This ensures the visualization is always meaningful regardless of absolute values + const scaleMax = thresholdMs + const meanPercent = Math.min(95, Math.max(5, (meanMs / scaleMax) * 100)) + + // Variation should be visible but proportional - minimum 8% width for visibility + const rawVariationPercent = (stdDevMs / scaleMax) * 100 + const variationPercent = Math.max(8, Math.min(40, rawVariationPercent)) + + // Current time position (if provided) + const currentPercent = currentTimeMs + ? Math.min(110, Math.max(0, (currentTimeMs / scaleMax) * 100)) + : null + + const barHeight = compact ? '16px' : '24px' + const markerTop = compact ? '-2px' : '-4px' + const markerHeight = compact ? '20px' : '32px' + + return ( +
+ {/* Speed bar container */} +
+ {/* Variation range (the "wiggle room") */} +
+ + {/* Average marker */} +
+ + {/* Current time marker (if provided) */} + {currentPercent !== null && ( +
100 + ? isDark + ? 'red.400' + : 'red.500' + : currentPercent > meanPercent + variationPercent + ? isDark + ? 'yellow.400' + : 'yellow.500' + : isDark + ? 'green.400' + : 'green.500', + borderRadius: '2px', + transition: 'left 0.1s linear, background-color 0.3s ease', + zIndex: 2, + })} + style={{ + top: markerTop, + height: markerHeight, + left: `calc(${Math.min(currentPercent, 105)}% - 2px)`, + }} + /> + )} + + {/* Threshold marker */} +
+
+ + {/* Labels */} +
+ {fastLabel} + + {averageLabel} + + {slowLabel} +
+
+ ) +} diff --git a/apps/web/src/components/practice/hooks/useInteractionPhase.ts b/apps/web/src/components/practice/hooks/useInteractionPhase.ts index 92f6aa2d..22f939ca 100644 --- a/apps/web/src/components/practice/hooks/useInteractionPhase.ts +++ b/apps/web/src/components/practice/hooks/useInteractionPhase.ts @@ -20,6 +20,8 @@ export interface AttemptInput { partIndex: number /** When the attempt started */ startTime: number + /** Accumulated time spent paused (ms) - subtract from elapsed time for actual response time */ + accumulatedPauseMs: number /** User's current answer input */ userAnswer: string /** Number of times user used backspace or had digits rejected */ @@ -94,6 +96,8 @@ export type InteractionPhase = | { phase: 'paused' resumePhase: ActivePhase + /** When the pause started (used to calculate pause duration on resume) */ + pauseStartedAt: number } /** Threshold for correction count before requiring manual submit */ @@ -124,7 +128,7 @@ export function transformActivePhase( if (phase.phase === 'paused') { const newResumePhase = transform(phase.resumePhase) if (newResumePhase === null) return phase - return { phase: 'paused', resumePhase: newResumePhase } + return { phase: 'paused', resumePhase: newResumePhase, pauseStartedAt: phase.pauseStartedAt } } const newPhase = transform(phase) return newPhase === null ? phase : newPhase @@ -143,6 +147,7 @@ export function createAttemptInput( slotIndex, partIndex, startTime: Date.now(), + accumulatedPauseMs: 0, userAnswer: '', correctionCount: 0, manualSubmitRequired: false, @@ -841,14 +846,47 @@ export function useInteractionPhase( setPhase((prev) => { if (prev.phase === 'paused' || prev.phase === 'loading' || prev.phase === 'complete') return prev - return { phase: 'paused', resumePhase: prev } + return { phase: 'paused', resumePhase: prev, pauseStartedAt: Date.now() } }) }, []) const resume = useCallback(() => { setPhase((prev) => { if (prev.phase !== 'paused') return prev - return prev.resumePhase + + // Calculate how long we were paused + const pauseDuration = Date.now() - prev.pauseStartedAt + + // Helper to add pause duration to an attempt + const addPauseDuration = (attempt: AttemptInput): AttemptInput => ({ + ...attempt, + accumulatedPauseMs: attempt.accumulatedPauseMs + pauseDuration, + }) + + // Update the attempt inside the resume phase to track accumulated pause time + const resumePhase = prev.resumePhase + switch (resumePhase.phase) { + case 'inputting': + return { ...resumePhase, attempt: addPauseDuration(resumePhase.attempt) } + case 'awaitingDisambiguation': + return { ...resumePhase, attempt: addPauseDuration(resumePhase.attempt) } + case 'helpMode': + return { ...resumePhase, attempt: addPauseDuration(resumePhase.attempt) } + case 'submitting': + return { ...resumePhase, attempt: addPauseDuration(resumePhase.attempt) } + case 'showingFeedback': + return { ...resumePhase, attempt: addPauseDuration(resumePhase.attempt) } + case 'transitioning': + // Update both outgoing (for accuracy of recorded time) and incoming + return { + ...resumePhase, + incoming: addPauseDuration(resumePhase.incoming), + } + case 'loading': + case 'complete': + // No attempt to update + return resumePhase + } }) }, []) diff --git a/apps/web/src/components/practice/index.ts b/apps/web/src/components/practice/index.ts index 6a3343de..b62c17b6 100644 --- a/apps/web/src/components/practice/index.ts +++ b/apps/web/src/components/practice/index.ts @@ -16,11 +16,15 @@ export { NumericKeypad } from './NumericKeypad' export { PracticeErrorBoundary } from './PracticeErrorBoundary' export type { SessionHudData } from './PracticeSubNav' export { PracticeSubNav } from './PracticeSubNav' +export { PracticeTimingDisplay } from './PracticeTimingDisplay' export type { ActiveSessionState, CurrentPhaseInfo, SkillProgress } from './ProgressDashboard' export { ProgressDashboard } from './ProgressDashboard' export type { AutoPauseStats, PauseInfo } from './SessionPausedModal' export { SessionPausedModal } from './SessionPausedModal' export { SessionSummary } from './SessionSummary' +export { SkillPerformanceReports } from './SkillPerformanceReports' +export type { SpeedMeterProps } from './SpeedMeter' +export { SpeedMeter } from './SpeedMeter' export { StartPracticeModal } from './StartPracticeModal' export type { StudentWithProgress } from './StudentSelector' export { StudentSelector } from './StudentSelector' diff --git a/apps/web/src/db/schema/player-skill-mastery.ts b/apps/web/src/db/schema/player-skill-mastery.ts index d95af965..d3377af1 100644 --- a/apps/web/src/db/schema/player-skill-mastery.ts +++ b/apps/web/src/db/schema/player-skill-mastery.ts @@ -100,6 +100,20 @@ export const playerSkillMastery = sqliteTable( * Resets to 0 when reinforcement is cleared or when help level 2+ is used */ reinforcementStreak: integer('reinforcement_streak').notNull().default(0), + + // ---- Response Time Tracking (for skill-level performance analysis) ---- + + /** + * Total response time in milliseconds across all attempts + * Used with responseTimeCount to calculate average: totalResponseTimeMs / responseTimeCount + */ + totalResponseTimeMs: integer('total_response_time_ms').notNull().default(0), + + /** + * Number of attempts with recorded response times + * May differ from `attempts` if some early data didn't track time + */ + responseTimeCount: integer('response_time_count').notNull().default(0), }, (table) => ({ /** Index for fast lookups by playerId */ diff --git a/apps/web/src/lib/curriculum/progress-manager.ts b/apps/web/src/lib/curriculum/progress-manager.ts index a77e07ab..87d24cf3 100644 --- a/apps/web/src/lib/curriculum/progress-manager.ts +++ b/apps/web/src/lib/curriculum/progress-manager.ts @@ -236,7 +236,7 @@ export async function recordSkillAttempt( } /** - * Record a skill attempt with help level tracking + * Record a skill attempt with help level tracking and response time * Applies credit multipliers based on help used and manages reinforcement * * Credit multipliers: @@ -248,12 +248,17 @@ export async function recordSkillAttempt( * - If help level >= threshold, mark skill as needing reinforcement * - If correct answer without heavy help, increment reinforcement streak * - After N consecutive correct answers, clear reinforcement flag + * + * Response time tracking: + * - Accumulates total response time for calculating per-skill averages + * - Only recorded if responseTimeMs is provided (> 0) */ export async function recordSkillAttemptWithHelp( playerId: string, skillId: string, isCorrect: boolean, - helpLevel: HelpLevel + helpLevel: HelpLevel, + responseTimeMs?: number ): Promise { const existing = await getSkillMastery(playerId, skillId) const now = new Date() @@ -302,6 +307,15 @@ export async function recordSkillAttemptWithHelp( reinforcementStreak = 0 } + // Calculate response time updates (only if provided) + const hasResponseTime = responseTimeMs !== undefined && responseTimeMs > 0 + const newTotalResponseTimeMs = hasResponseTime + ? existing.totalResponseTimeMs + responseTimeMs + : existing.totalResponseTimeMs + const newResponseTimeCount = hasResponseTime + ? existing.responseTimeCount + 1 + : existing.responseTimeCount + await db .update(schema.playerSkillMastery) .set({ @@ -314,12 +328,17 @@ export async function recordSkillAttemptWithHelp( needsReinforcement, lastHelpLevel: helpLevel, reinforcementStreak, + totalResponseTimeMs: newTotalResponseTimeMs, + responseTimeCount: newResponseTimeCount, }) .where(eq(schema.playerSkillMastery.id, existing.id)) return (await getSkillMastery(playerId, skillId))! } + // Calculate response time for new record (only if provided) + const hasResponseTime = responseTimeMs !== undefined && responseTimeMs > 0 + // Create new record with help tracking const newRecord: NewPlayerSkillMastery = { playerId, @@ -332,6 +351,8 @@ export async function recordSkillAttemptWithHelp( needsReinforcement: isHeavyHelp, lastHelpLevel: helpLevel, reinforcementStreak: 0, + totalResponseTimeMs: hasResponseTime ? responseTimeMs : 0, + responseTimeCount: hasResponseTime ? 1 : 0, } await db.insert(schema.playerSkillMastery).values(newRecord) @@ -340,16 +361,24 @@ export async function recordSkillAttemptWithHelp( /** * Record multiple skill attempts with help tracking (for batch updates after a problem) + * Response time is shared across all skills since they come from the same problem */ export async function recordSkillAttemptsWithHelp( playerId: string, skillResults: Array<{ skillId: string; isCorrect: boolean }>, - helpLevel: HelpLevel + helpLevel: HelpLevel, + responseTimeMs?: number ): Promise { const results: PlayerSkillMastery[] = [] for (const { skillId, isCorrect } of skillResults) { - const result = await recordSkillAttemptWithHelp(playerId, skillId, isCorrect, helpLevel) + const result = await recordSkillAttemptWithHelp( + playerId, + skillId, + isCorrect, + helpLevel, + responseTimeMs + ) results.push(result) } @@ -611,3 +640,150 @@ export async function initializeStudent(playerId: string): Promise { + const allSkills = await getAllSkillMastery(playerId) + + // Calculate performance data for each skill + const skills: SkillPerformance[] = allSkills.map((s) => ({ + skillId: s.skillId, + masteryLevel: s.masteryLevel as MasteryLevel, + attempts: s.attempts, + accuracy: s.attempts > 0 ? s.correct / s.attempts : 0, + avgResponseTimeMs: + s.responseTimeCount > 0 ? Math.round(s.totalResponseTimeMs / s.responseTimeCount) : null, + responseTimeCount: s.responseTimeCount, + })) + + // Calculate overall average response time (only from skills with sufficient data) + const skillsWithTiming = skills.filter( + (s) => + s.avgResponseTimeMs !== null && + s.responseTimeCount >= PERFORMANCE_THRESHOLDS.minResponsesForTiming + ) + const overallAvgResponseTimeMs = + skillsWithTiming.length > 0 + ? Math.round( + skillsWithTiming.reduce((sum, s) => sum + (s.avgResponseTimeMs ?? 0), 0) / + skillsWithTiming.length + ) + : null + + // Identify fast skills (significantly faster than average) + const fastSkills = + overallAvgResponseTimeMs !== null + ? skillsWithTiming.filter( + (s) => + s.avgResponseTimeMs !== null && + s.avgResponseTimeMs < + overallAvgResponseTimeMs * (1 - PERFORMANCE_THRESHOLDS.speedDeviationPercent) + ) + : [] + + // Identify slow skills (significantly slower than average) + const slowSkills = + overallAvgResponseTimeMs !== null + ? skillsWithTiming.filter( + (s) => + s.avgResponseTimeMs !== null && + s.avgResponseTimeMs > + overallAvgResponseTimeMs * (1 + PERFORMANCE_THRESHOLDS.speedDeviationPercent) + ) + : [] + + // Identify low accuracy skills + const lowAccuracySkills = skills.filter( + (s) => + s.attempts >= PERFORMANCE_THRESHOLDS.minResponsesForTiming && + s.accuracy < PERFORMANCE_THRESHOLDS.minAccuracyThreshold + ) + + // Get skills needing reinforcement + const reinforcementRecords = await getSkillsNeedingReinforcement(playerId) + const reinforcementSkillIds = new Set(reinforcementRecords.map((r) => r.skillId)) + const reinforcementSkills = skills.filter((s) => reinforcementSkillIds.has(s.skillId)) + + return { + skills, + overallAvgResponseTimeMs, + fastSkills, + slowSkills, + lowAccuracySkills, + reinforcementSkills, + } +} + +/** + * Get skills ranked by response time (slowest first) + * Useful for identifying skills that need practice + */ +export async function getSkillsByResponseTime( + playerId: string, + order: 'slowest' | 'fastest' = 'slowest' +): Promise { + const analysis = await analyzeSkillPerformance(playerId) + + // Filter to only skills with timing data + const skillsWithTiming = analysis.skills.filter( + (s) => + s.avgResponseTimeMs !== null && + s.responseTimeCount >= PERFORMANCE_THRESHOLDS.minResponsesForTiming + ) + + // Sort by response time + return skillsWithTiming.sort((a, b) => { + const timeA = a.avgResponseTimeMs ?? 0 + const timeB = b.avgResponseTimeMs ?? 0 + return order === 'slowest' ? timeB - timeA : timeA - timeB + }) +} diff --git a/apps/web/src/lib/curriculum/session-planner.ts b/apps/web/src/lib/curriculum/session-planner.ts index ccbd05d5..44e6025a 100644 --- a/apps/web/src/lib/curriculum/session-planner.ts +++ b/apps/web/src/lib/curriculum/session-planner.ts @@ -38,7 +38,12 @@ import { type getPhaseSkillConstraints, } from './definitions' import { generateProblemFromConstraints } from './problem-generator' -import { getAllSkillMastery, getPlayerCurriculum, getRecentSessions } from './progress-manager' +import { + getAllSkillMastery, + getPlayerCurriculum, + getRecentSessions, + recordSkillAttemptsWithHelp, +} from './progress-manager' // ============================================================================ // Plan Generation @@ -452,6 +457,21 @@ export async function recordSlotResult( .where(eq(schema.sessionPlans.id, planId)) .returning() + // Update global skill mastery with response time data + // This builds the per-kid stats for identifying strengths/weaknesses + if (result.skillsExercised && result.skillsExercised.length > 0) { + const skillResults = result.skillsExercised.map((skillId) => ({ + skillId, + isCorrect: result.isCorrect, + })) + await recordSkillAttemptsWithHelp( + plan.playerId, + skillResults, + result.helpLevelUsed, + result.responseTimeMs + ) + } + return updated }