diff --git a/messages/en.json b/messages/en.json index d7cb273..7765dbf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -681,5 +681,63 @@ "contest_results_no_results_description": "No participants have submitted solutions yet.", "contest_results_solved_partial": "{solved} solved · {partial} partial", "contest_results_score_submissions": "Score / Submissions", - "contest_results_error_generic": "Failed to load contest results" + "contest_results_error_generic": "Failed to load contest results", + "admin_contest_tasks_view_user_stats": "View User Stats", + "task_user_stats_title": "Task User Statistics", + "task_user_stats_subtitle": "User performance statistics for Task #{taskId} in Contest #{contestId}", + "task_user_stats_loading": "Loading user statistics...", + "task_user_stats_load_error": "Failed to load user statistics", + "task_user_stats_total_users": "Total Users", + "task_user_stats_average_score": "Average Score", + "task_user_stats_perfect_scores": "Perfect Scores", + "task_user_stats_total_submissions": "Total Submissions", + "task_user_stats_all_users": "All User Statistics", + "task_user_stats_participants": "{count} participants", + "task_user_stats_rank": "Rank", + "task_user_stats_name": "Name", + "task_user_stats_username": "Username", + "task_user_stats_best_score": "Best Score", + "task_user_stats_submissions": "Submissions", + "task_user_stats_previous": "Previous", + "task_user_stats_next": "Next", + "task_user_stats_page_info": "Page {current} of {total}", + "task_user_stats_showing": "Showing {start} - {end} of {total}", + "task_user_stats_no_data_title": "No statistics available", + "task_user_stats_no_data_description": "No users have attempted this task yet.", + "contest_user_stats_title": "Contest User Statistics", + "contest_user_stats_subtitle": "Overall user performance statistics for Contest #{contestId}", + "contest_user_stats_loading": "Loading contest statistics...", + "contest_user_stats_load_error": "Failed to load contest statistics", + "contest_user_stats_total_users": "Total Users", + "contest_user_stats_average_score": "Average Score", + "contest_user_stats_total_solved": "Total Tasks Solved", + "contest_user_stats_total_attempted": "Total Tasks Attempted", + "contest_user_stats_all_users": "All User Statistics", + "contest_user_stats_participants": "{count} participants", + "contest_user_stats_rank": "Rank", + "contest_user_stats_name": "Name", + "contest_user_stats_username": "Username", + "contest_user_stats_total_score": "Total Score", + "contest_user_stats_solved": "Solved", + "contest_user_stats_partial": "Partial", + "contest_user_stats_attempted": "Attempted", + "contest_user_stats_previous": "Previous", + "contest_user_stats_next": "Next", + "contest_user_stats_page_info": "Page {current} of {total}", + "contest_user_stats_showing": "Showing {start} - {end} of {total}", + "contest_user_stats_no_data_title": "No statistics available", + "contest_user_stats_no_data_description": "No users have participated in this contest yet.", + "admin_contest_view_all_user_stats": "View Contest Stats", + "admin_contests_card_view_user_stats": "View User Stats", + "contest_user_stats_task_breakdown": "Task Breakdown", + "contest_user_stats_task_title": "Task", + "contest_user_stats_task_score": "Score", + "contest_user_stats_task_attempts": "Attempts", + "contest_user_stats_task_status": "Status", + "contest_user_stats_task_solved": "Solved", + "contest_user_stats_task_partial": "Partial", + "contest_user_stats_task_failed": "Failed", + "contest_user_stats_task_not_attempted": "Not Attempted", + "contest_user_stats_expand_details": "Click to expand task breakdown", + "contest_user_stats_collapse_details": "Click to collapse task breakdown" } diff --git a/messages/pl.json b/messages/pl.json index 696772d..2b4a689 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -681,5 +681,63 @@ "contest_results_no_results_description": "Żaden uczestnik nie przesłał jeszcze rozwiązań.", "contest_results_solved_partial": "{solved} rozwiązane · {partial} częściowo", "contest_results_score_submissions": "Wynik / Zgłoszenia", - "contest_results_error_generic": "Nie udało się załadować wyników konkursu" + "contest_results_error_generic": "Nie udało się załadować wyników konkursu", + "admin_contest_tasks_view_user_stats": "Zobacz statystyki użytkowników", + "task_user_stats_title": "Statystyki użytkowników zadania", + "task_user_stats_subtitle": "Statystyki wydajności użytkowników dla zadania #{taskId} w konkursie #{contestId}", + "task_user_stats_loading": "Ładowanie statystyk użytkowników...", + "task_user_stats_load_error": "Nie udało się załadować statystyk użytkowników", + "task_user_stats_total_users": "Liczba użytkowników", + "task_user_stats_average_score": "Średni wynik", + "task_user_stats_perfect_scores": "Idealne wyniki", + "task_user_stats_total_submissions": "Wszystkie zgłoszenia", + "task_user_stats_all_users": "Wszystkie statystyki użytkowników", + "task_user_stats_participants": "{count} uczestników", + "task_user_stats_rank": "Miejsce", + "task_user_stats_name": "Imię i nazwisko", + "task_user_stats_username": "Nazwa użytkownika", + "task_user_stats_best_score": "Najlepszy wynik", + "task_user_stats_submissions": "Zgłoszenia", + "task_user_stats_previous": "Poprzednia", + "task_user_stats_next": "Następna", + "task_user_stats_page_info": "Strona {current} z {total}", + "task_user_stats_showing": "Pokazywanie {start} - {end} z {total}", + "task_user_stats_no_data_title": "Brak dostępnych statystyk", + "task_user_stats_no_data_description": "Żaden użytkownik nie podjął jeszcze próby rozwiązania tego zadania.", + "contest_user_stats_title": "Statystyki użytkowników konkursu", + "contest_user_stats_subtitle": "Ogólne statystyki wydajności użytkowników dla konkursu #{contestId}", + "contest_user_stats_loading": "Ładowanie statystyk konkursu...", + "contest_user_stats_load_error": "Nie udało się załadować statystyk konkursu", + "contest_user_stats_total_users": "Liczba użytkowników", + "contest_user_stats_average_score": "Średni wynik", + "contest_user_stats_total_solved": "Łącznie rozwiązanych zadań", + "contest_user_stats_total_attempted": "Łącznie podjętych prób", + "contest_user_stats_all_users": "Wszystkie statystyki użytkowników", + "contest_user_stats_participants": "{count} uczestników", + "contest_user_stats_rank": "Miejsce", + "contest_user_stats_name": "Imię i nazwisko", + "contest_user_stats_username": "Nazwa użytkownika", + "contest_user_stats_total_score": "Całkowity wynik", + "contest_user_stats_solved": "Rozwiązane", + "contest_user_stats_partial": "Częściowe", + "contest_user_stats_attempted": "Podjęte", + "contest_user_stats_previous": "Poprzednia", + "contest_user_stats_next": "Następna", + "contest_user_stats_page_info": "Strona {current} z {total}", + "contest_user_stats_showing": "Pokazywanie {start} - {end} z {total}", + "contest_user_stats_no_data_title": "Brak dostępnych statystyk", + "contest_user_stats_no_data_description": "Żaden użytkownik nie wziął jeszcze udziału w tym konkursie.", + "admin_contest_view_all_user_stats": "Zobacz statystyki konkursu", + "admin_contests_card_view_user_stats": "Zobacz statystyki użytkowników", + "contest_user_stats_task_breakdown": "Rozbicie na zadania", + "contest_user_stats_task_title": "Zadanie", + "contest_user_stats_task_score": "Wynik", + "contest_user_stats_task_attempts": "Próby", + "contest_user_stats_task_status": "Status", + "contest_user_stats_task_solved": "Rozwiązane", + "contest_user_stats_task_partial": "Częściowe", + "contest_user_stats_task_failed": "Nieudane", + "contest_user_stats_task_not_attempted": "Nie próbowano", + "contest_user_stats_expand_details": "Kliknij, aby rozwinąć rozbicie na zadania", + "contest_user_stats_collapse_details": "Kliknij, aby zwinąć rozbicie na zadania" } diff --git a/src/lib/components/dashboard/contests/AdminContestCard.svelte b/src/lib/components/dashboard/contests/AdminContestCard.svelte index 736ee30..11cc0b5 100644 --- a/src/lib/components/dashboard/contests/AdminContestCard.svelte +++ b/src/lib/components/dashboard/contests/AdminContestCard.svelte @@ -171,6 +171,14 @@ {m.admin_contests_card_view_collaborators()} + diff --git a/src/lib/components/dashboard/utils.ts b/src/lib/components/dashboard/utils.ts index ba85a90..5aaf73b 100644 --- a/src/lib/components/dashboard/utils.ts +++ b/src/lib/components/dashboard/utils.ts @@ -25,6 +25,16 @@ export function getDashboardTitleTranslationFromPathname(pathname: string): stri return m.header_task_details(); } + // Check for teacher contest task user stats (e.g., /dashboard/teacher/contests/[contestId]/tasks/[taskId]/user-stats) + if (path.match(/^\/dashboard\/teacher\/contests\/\d+\/tasks\/\d+\/user-stats/)) { + return m.task_user_stats_title(); + } + + // Check for teacher contest user stats (e.g., /dashboard/teacher/contests/[contestId]/user-stats) + if (path.match(/^\/dashboard\/teacher\/contests\/\d+\/user-stats/)) { + return m.contest_user_stats_title(); + } + // Check for admin contest registration requests (e.g., /dashboard/admin/contests/[contestId]/registration-requests) if (path.match(/^\/dashboard\/admin\/contests\/\d+\/registration-requests/)) { return m.admin_registration_requests_title(); diff --git a/src/lib/dto/contest.ts b/src/lib/dto/contest.ts index 775bbf4..b916399 100644 --- a/src/lib/dto/contest.ts +++ b/src/lib/dto/contest.ts @@ -214,6 +214,13 @@ export interface UserContestStats { taskBreakdown: UserTaskPerformance[]; } +export interface TaskUserStats { + user: UserInfo; + bestScore: number; + submissionCount: number; + bestSubmissionId: number; +} + export interface ContestDetailed { id: number; name: string; diff --git a/src/lib/services/ContestsManagementService.ts b/src/lib/services/ContestsManagementService.ts index 821492f..3737e1d 100644 --- a/src/lib/services/ContestsManagementService.ts +++ b/src/lib/services/ContestsManagementService.ts @@ -7,7 +7,8 @@ import type { AddContestTaskDto, ContestTask as ContestTaskRelation, ManagedContest, - UserContestStats + UserContestStats, + TaskUserStats } from '$lib/dto/contest'; import type { Task, ContestTask } from '$lib/dto/task'; import type { Cookies } from '@sveltejs/kit'; @@ -260,6 +261,23 @@ export class ContestsManagementService { throw error; } } + + async getTaskUserStats(contestId: number, taskId: number): Promise { + try { + const url = `/contests-management/contests/${contestId}/tasks/${taskId}/user-stats`; + + const response = await this.apiClient.get>({ + url + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get task user stats:', error.toJSON()); + throw error; + } + throw error; + } + } } export function createContestsManagementService(cookies: Cookies): ContestsManagementService { diff --git a/src/routes/dashboard/teacher/contests/[contestId]/tasks/+page.svelte b/src/routes/dashboard/teacher/contests/[contestId]/tasks/+page.svelte index a9423ea..2c8ba5b 100644 --- a/src/routes/dashboard/teacher/contests/[contestId]/tasks/+page.svelte +++ b/src/routes/dashboard/teacher/contests/[contestId]/tasks/+page.svelte @@ -2,6 +2,7 @@ import { getContestTasks, removeTaskFromContest } from './tasks.remote'; import { LoadingSpinner, ErrorCard, EmptyState } from '$lib/components/common'; import { RemoveTaskFromContestButton } from '$lib/components/dashboard/admin/contests'; + import { Button } from '$lib/components/ui/button'; import * as m from '$lib/paraglide/messages'; import ClipboardList from '@lucide/svelte/icons/clipboard-list'; import User from '@lucide/svelte/icons/user'; @@ -9,6 +10,7 @@ import Clock from '@lucide/svelte/icons/clock'; import CheckCircle from '@lucide/svelte/icons/check-circle'; import XCircle from '@lucide/svelte/icons/x-circle'; + import ChartBar from '@lucide/svelte/icons/chart-bar'; import { formatDate } from '$lib/utils'; interface Props { @@ -23,6 +25,14 @@

{m.admin_contest_tasks_page_title({ contestId: data.contestId })}

+ @@ -72,6 +82,15 @@ {m.admin_contest_tasks_submission_open_no()} {/if} + Promise<{ contestId: number }>; +}) => { + const taskId = parseInt(params.taskId, 10); + const parentData = await parent(); + + if (isNaN(taskId)) { + throw error(400, 'Invalid task ID'); + } + + return { + contestId: parentData.contestId, + taskId + }; +}; diff --git a/src/routes/dashboard/teacher/contests/[contestId]/tasks/[taskId]/user-stats/+page.svelte b/src/routes/dashboard/teacher/contests/[contestId]/tasks/[taskId]/user-stats/+page.svelte new file mode 100644 index 0000000..8549d12 --- /dev/null +++ b/src/routes/dashboard/teacher/contests/[contestId]/tasks/[taskId]/user-stats/+page.svelte @@ -0,0 +1,254 @@ + + +
+
+

+ {m.task_user_stats_title()} +

+

+ {m.task_user_stats_subtitle({ contestId: data.contestId, taskId: data.taskId })} +

+
+ + {#if statsQuery.error} + statsQuery.refresh()} + /> + {:else if statsQuery.loading} + + {:else if statsQuery.current} + +
+ + + + + {m.task_user_stats_total_users()} + + + +

{sortedStats.length}

+
+
+ + + + + + {m.task_user_stats_average_score()} + + + +

{averageScore.toFixed(1)}%

+
+
+ + + + + + {m.task_user_stats_perfect_scores()} + + + +

{perfectScores}

+
+
+ + + + + + {m.task_user_stats_total_submissions()} + + + +

{totalSubmissions}

+
+
+
+ + + {#if sortedStats.length > 0} + + +
+ + + {m.task_user_stats_all_users()} + +

+ {m.task_user_stats_participants({ count: sortedStats.length })} +

+
+
+ +
+ + + + {m.task_user_stats_rank()} + {m.task_user_stats_name()} + + {m.task_user_stats_best_score()} + {m.task_user_stats_submissions()} + + + + {#each paginatedStats as userStat, index (userStat.user.id)} + {@const rank = (currentPage - 1) * pageSize + index + 1} + {@const RankIcon = getRankIcon(rank)} + + +
+ {#if RankIcon} +
+ +
+ {:else} + {rank} + {/if} +
+
+ + {userStat.user.name} + {userStat.user.surname} + + + + + {userStat.bestScore.toFixed(1)}% + + + + {userStat.submissionCount} + +
+ {/each} +
+
+
+ + + {#if totalPages > 1} +
+

+ {m.task_user_stats_showing({ + start: (currentPage - 1) * pageSize + 1, + end: Math.min(currentPage * pageSize, sortedStats.length), + total: sortedStats.length + })} +

+
+ + + {m.task_user_stats_page_info({ current: currentPage, total: totalPages })} + + +
+
+ {/if} +
+
+ {:else} + + {/if} + {/if} +
diff --git a/src/routes/dashboard/teacher/contests/[contestId]/tasks/[taskId]/user-stats/user-stats.remote.ts b/src/routes/dashboard/teacher/contests/[contestId]/tasks/[taskId]/user-stats/user-stats.remote.ts new file mode 100644 index 0000000..ded4740 --- /dev/null +++ b/src/routes/dashboard/teacher/contests/[contestId]/tasks/[taskId]/user-stats/user-stats.remote.ts @@ -0,0 +1,31 @@ +import { query, getRequestEvent } from '$app/server'; +import * as v from 'valibot'; +import { createContestsManagementService } from '$lib/services/ContestsManagementService'; +import { ApiError } from '$lib/services/ApiService'; +import { error } from '@sveltejs/kit'; +import type { TaskUserStats } from '$lib/dto/contest'; + +const paramsSchema = v.object({ + contestId: v.pipe(v.number(), v.integer()), + taskId: v.pipe(v.number(), v.integer()) +}); + +export const getTaskUserStats = query( + paramsSchema, + async (params: { contestId: number; taskId: number }): Promise => { + const { cookies } = getRequestEvent(); + + try { + const svc = createContestsManagementService(cookies); + return await svc.getTaskUserStats(params.contestId, params.taskId); + } catch (err) { + console.error('Failed to load task user stats:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, 'Failed to load task user stats'); + } + } +); diff --git a/src/routes/dashboard/teacher/contests/[contestId]/user-stats/+page.server.ts b/src/routes/dashboard/teacher/contests/[contestId]/user-stats/+page.server.ts new file mode 100644 index 0000000..b228f4f --- /dev/null +++ b/src/routes/dashboard/teacher/contests/[contestId]/user-stats/+page.server.ts @@ -0,0 +1,7 @@ +export const load = async ({ parent }: { parent: () => Promise<{ contestId: number }> }) => { + const parentData = await parent(); + + return { + contestId: parentData.contestId + }; +}; diff --git a/src/routes/dashboard/teacher/contests/[contestId]/user-stats/+page.svelte b/src/routes/dashboard/teacher/contests/[contestId]/user-stats/+page.svelte new file mode 100644 index 0000000..8611a12 --- /dev/null +++ b/src/routes/dashboard/teacher/contests/[contestId]/user-stats/+page.svelte @@ -0,0 +1,393 @@ + + +
+
+

+ {m.contest_user_stats_title()} +

+

+ {m.contest_user_stats_subtitle({ contestId: data.contestId })} +

+
+ + {#if statsQuery.error} + statsQuery.refresh()} + /> + {:else if statsQuery.loading} + + {:else if statsQuery.current} + +
+ + + + + {m.contest_user_stats_total_users()} + + + +

{sortedStats.length}

+
+
+ + + + + + {m.contest_user_stats_average_score()} + + + +

{averageScore.toFixed(1)}%

+
+
+ + + + + + {m.contest_user_stats_total_solved()} + + + +

{totalTasksSolved}

+
+
+ + + + + + {m.contest_user_stats_total_attempted()} + + + +

{totalTasksAttempted}

+
+
+
+ + + {#if sortedStats.length > 0} + + +
+ + + {m.contest_user_stats_all_users()} + +

+ {m.contest_user_stats_participants({ count: sortedStats.length })} +

+
+
+ +
+ + + + + {m.contest_user_stats_rank()} + {m.contest_user_stats_name()} + + {m.contest_user_stats_total_score()} + {m.contest_user_stats_solved()} + + + + + + {#each paginatedStats as userStat, index (userStat.user.id)} + {@const rank = (currentPage - 1) * pageSize + index + 1} + {@const RankIcon = getRankIcon(rank)} + {@const isExpanded = expandedRows.has(userStat.user.id)} + + toggleRowExpansion(userStat.user.id)} + title={isExpanded + ? m.contest_user_stats_collapse_details() + : m.contest_user_stats_expand_details()} + > + + + + +
+ {#if RankIcon} +
+ +
+ {:else} + {rank} + {/if} +
+
+ + {userStat.user.name} + {userStat.user.surname} + + + + {userStat.totalScore.toFixed(1)}% + + + {userStat.tasksSolved} + + + +
+ + {#if isExpanded} + + +
+

+ {m.contest_user_stats_task_breakdown()} +

+
+ + + + + + + + + + + {#each userStat.taskBreakdown as task (task.taskId)} + {@const statusBadge = getTaskStatusBadge(task)} + + + + + + + {/each} + +
+ {m.contest_user_stats_task_title()} + + {m.contest_user_stats_task_score()} + + {m.contest_user_stats_task_attempts()} + + {m.contest_user_stats_task_status()} +
+ {task.taskTitle} + + 0} + > + {task.bestScore.toFixed(1)}% + + + {task.attemptCount} + + + {statusBadge.text} + +
+
+
+
+
+ {/if} + {/each} +
+
+
+ + + {#if totalPages > 1} +
+

+ {m.contest_user_stats_showing({ + start: (currentPage - 1) * pageSize + 1, + end: Math.min(currentPage * pageSize, sortedStats.length), + total: sortedStats.length + })} +

+
+ + + {m.contest_user_stats_page_info({ current: currentPage, total: totalPages })} + + +
+
+ {/if} +
+
+ {:else} + + {/if} + {/if} +
diff --git a/src/routes/dashboard/teacher/contests/[contestId]/user-stats/user-stats.remote.ts b/src/routes/dashboard/teacher/contests/[contestId]/user-stats/user-stats.remote.ts new file mode 100644 index 0000000..1a79513 --- /dev/null +++ b/src/routes/dashboard/teacher/contests/[contestId]/user-stats/user-stats.remote.ts @@ -0,0 +1,30 @@ +import { query, getRequestEvent } from '$app/server'; +import * as v from 'valibot'; +import { createContestsManagementService } from '$lib/services/ContestsManagementService'; +import { ApiError } from '$lib/services/ApiService'; +import { error } from '@sveltejs/kit'; +import type { UserContestStats } from '$lib/dto/contest'; + +const paramsSchema = v.object({ + contestId: v.pipe(v.number(), v.integer()) +}); + +export const getContestUserStats = query( + paramsSchema, + async (params: { contestId: number }): Promise => { + const { cookies } = getRequestEvent(); + + try { + const svc = createContestsManagementService(cookies); + return await svc.getUserStats(params.contestId); + } catch (err) { + console.error('Failed to load contest user stats:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, 'Failed to load contest user stats'); + } + } +);