diff --git a/messages/en.json b/messages/en.json index 7e4a072..15b0112 100644 --- a/messages/en.json +++ b/messages/en.json @@ -662,5 +662,11 @@ "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", + "teacher_contest_results_title": "Contest Results (Teacher View)", + "teacher_contest_results_total_participants": "Total Participants", + "teacher_contest_results_total_submissions": "Total Submissions", + "teacher_contest_results_tasks_solved": "Tasks Solved", + "teacher_contest_results_avg_score": "Average Score", + "user_contest_results_no_results_description": "You haven't submitted any solutions yet." } diff --git a/messages/pl.json b/messages/pl.json index ef78b41..a1426bc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -662,5 +662,11 @@ "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", + "teacher_contest_results_title": "Wyniki Konkursu (Widok Nauczyciela)", + "teacher_contest_results_total_participants": "Liczba Uczestników", + "teacher_contest_results_total_submissions": "Liczba Zgłoszeń", + "teacher_contest_results_tasks_solved": "Rozwiązane Zadania", + "teacher_contest_results_avg_score": "Średni Wynik", + "user_contest_results_no_results_description": "Nie przesłałeś jeszcze żadnych rozwiązań." } diff --git a/src/lib/components/dashboard/admin/tasks/TasksUploadDialog.svelte b/src/lib/components/dashboard/admin/tasks/TasksUploadDialog.svelte index 2a1da36..95163e4 100644 --- a/src/lib/components/dashboard/admin/tasks/TasksUploadDialog.svelte +++ b/src/lib/components/dashboard/admin/tasks/TasksUploadDialog.svelte @@ -57,11 +57,17 @@ {m.admin_tasks_dialog_description()} {#if uploadLimit.loading} - ({m.admin_tasks_upload_limit_loading()}) + ({m.admin_tasks_upload_limit_loading()}) {:else if uploadLimit.error} - ({m.admin_tasks_upload_limit_unavailable()}) + ({m.admin_tasks_upload_limit_unavailable()}) {:else if uploadLimit.current} - ({m.admin_tasks_upload_limit({ limit: MAX_UPLOAD_MB })}) + ({m.admin_tasks_upload_limit({ limit: MAX_UPLOAD_MB })}) {/if} diff --git a/src/routes/dashboard/teacher/contests/[contestId]/results/+page.svelte b/src/routes/dashboard/teacher/contests/[contestId]/results/+page.svelte new file mode 100644 index 0000000..151fbd4 --- /dev/null +++ b/src/routes/dashboard/teacher/contests/[contestId]/results/+page.svelte @@ -0,0 +1,395 @@ + + +
+
+

+ {m.teacher_contest_results_title()} +

+ {#if resultsQuery.current?.contest} +
+

{resultsQuery.current.contest.name}

+

+ {formatContestDate(resultsQuery.current.contest.startAt)} - {formatContestDate( + resultsQuery.current.contest.endAt + )} +

+
+ {/if} +
+ + {#if resultsQuery.error} + resultsQuery.refresh()} + /> + {:else if resultsQuery.loading} + + {:else if resultsQuery.current} + + {#if contestStats} +
+ + + + + {m.teacher_contest_results_total_participants()} + + + +

+ {contestStats.totalParticipants} +

+
+
+ + + + + + {m.teacher_contest_results_total_submissions()} + + + +

+ {contestStats.totalSubmissions} +

+
+
+ + + + + + {m.teacher_contest_results_tasks_solved()} + + + +

+ {contestStats.totalTasksSolved} +

+
+
+ + + + + + {m.teacher_contest_results_avg_score()} + + + +

+ {contestStats.avgScore.toFixed(1)} +

+
+
+
+ {/if} + + + {#if sortedLeaderboard.length >= 3} + + + + + {m.contest_results_top_champions()} + + + +
+ + {#if sortedLeaderboard[1]} +
+
+ +
+
+

+ {sortedLeaderboard[1].user.name} + {sortedLeaderboard[1].user.surname} +

+

@{sortedLeaderboard[1].user.username}

+

+ {sortedLeaderboard[1].totalScore.toFixed(1)} +

+

+ {m.contest_results_solved_partial({ + solved: sortedLeaderboard[1].tasksSolved, + partial: sortedLeaderboard[1].tasksPartiallySolved + })} +

+
+
+ {/if} + + + {#if sortedLeaderboard[0]} +
+
+ +
+
+

+ {sortedLeaderboard[0].user.name} + {sortedLeaderboard[0].user.surname} +

+

@{sortedLeaderboard[0].user.username}

+

+ {sortedLeaderboard[0].totalScore.toFixed(1)} +

+

+ {m.contest_results_solved_partial({ + solved: sortedLeaderboard[0].tasksSolved, + partial: sortedLeaderboard[0].tasksPartiallySolved + })} +

+
+
+ {/if} + + + {#if sortedLeaderboard[2]} +
+
+ +
+
+

+ {sortedLeaderboard[2].user.name} + {sortedLeaderboard[2].user.surname} +

+

@{sortedLeaderboard[2].user.username}

+

+ {sortedLeaderboard[2].totalScore.toFixed(1)} +

+

+ {m.contest_results_solved_partial({ + solved: sortedLeaderboard[2].tasksSolved, + partial: sortedLeaderboard[2].tasksPartiallySolved + })} +

+
+
+ {/if} +
+
+
+ {/if} + + + {#if sortedLeaderboard.length > 0} + + +
+ + + {m.contest_results_leaderboard()} + +

+ {m.contest_results_participants({ count: sortedLeaderboard.length })} +

+
+
+ +
+ + + + {m.contest_results_rank()} + {m.contest_results_name()} + + {m.contest_results_total_score()} + + + + + + {#each paginatedLeaderboard as userStats, index (userStats.user.id)} + {@const rank = (currentPage - 1) * pageSize + index + 1} + {@const RankIcon = getRankIcon(rank)} + + +
+ {#if RankIcon} +
+ +
+ {:else} + {rank} + {/if} +
+
+ + {userStats.user.name} + {userStats.user.surname} + + + + {userStats.totalScore.toFixed(1)} + + + +
+ {/each} +
+
+
+ + + {#if totalPages > 1} +
+

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

+
+ + + {m.contest_results_page_info({ current: currentPage, total: totalPages })} + + +
+
+ {/if} +
+
+ {:else} + + {/if} + {/if} +
diff --git a/src/routes/dashboard/teacher/contests/[contestId]/results/results.remote.ts b/src/routes/dashboard/teacher/contests/[contestId]/results/results.remote.ts new file mode 100644 index 0000000..ff40d49 --- /dev/null +++ b/src/routes/dashboard/teacher/contests/[contestId]/results/results.remote.ts @@ -0,0 +1,47 @@ +import { query, getRequestEvent } from '$app/server'; +import { createContestsManagementService } from '$lib/services/ContestsManagementService'; +import { createContestService } from '$lib/services/ContestService'; +import { ApiError } from '$lib/services/ApiService'; +import { error } from '@sveltejs/kit'; +import * as v from 'valibot'; +import type { UserContestStats, ContestDetailed } from '$lib/dto/contest'; +import * as m from '$lib/paraglide/messages'; + +const contestResultsSchema = v.object({ + contestId: v.number() +}); + +export const getContestResults = query( + contestResultsSchema, + async (params: { + contestId: number; + }): Promise<{ + contest: ContestDetailed; + userStats: UserContestStats[]; + }> => { + const { cookies } = getRequestEvent(); + + try { + const contestsManagementService = createContestsManagementService(cookies); + const contestService = createContestService(cookies); + + const [userStats, contest] = await Promise.all([ + contestsManagementService.getUserStats(params.contestId), + contestService.getContest(params.contestId) + ]); + + return { + contest, + userStats + }; + } catch (err) { + console.error('Failed to load contest results:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, m.contest_results_error_generic()); + } + } +); diff --git a/src/routes/dashboard/user/contests/[contestId]/results/+layout.server.ts b/src/routes/dashboard/user/contests/[contestId]/results/+layout.server.ts index b10e728..7a84eac 100644 --- a/src/routes/dashboard/user/contests/[contestId]/results/+layout.server.ts +++ b/src/routes/dashboard/user/contests/[contestId]/results/+layout.server.ts @@ -1,9 +1,5 @@ import { error } from '@sveltejs/kit'; import { createContestService } from '$lib/services/ContestService'; -import { createApiClient } from '$lib/services/ApiService'; -import { AccessControlService } from '$lib/services/AccessControlService'; -import { UserRole } from '$lib/dto/jwt'; -import { ContestStatus } from '$lib/dto/contest'; import * as m from '$lib/paraglide/messages'; export const load = async ({ @@ -22,38 +18,13 @@ export const load = async ({ } const parentData = await parent(); - const user = parentData.user; - // Fetch contest details to check status + // Fetch contest details const contestService = createContestService(cookies); const contest = await contestService.getContest(contestId); - // Check access: contest must be past OR user is admin/teacher with access - const isAdmin = user.role === UserRole.Admin; - const isTeacher = user.role === UserRole.Teacher; - const isContestPast = contest.status === ContestStatus.Past; - - if (!isContestPast) { - // If contest is not past, only admin or teacher with access can view - if (!isAdmin && !isTeacher) { - throw error(403, m.contest_results_access_denied()); - } - - // For teachers, verify they have access to this contest - if (isTeacher) { - const apiClient = createApiClient(cookies); - const accessControlService = new AccessControlService(apiClient); - const result = await accessControlService.getContestCollaborators(contestId); - - if (!result.success) { - throw error(403, m.contest_results_access_denied()); - } - } - } - return { contestId, - currentUserId: user.userId, contest }; }; diff --git a/src/routes/dashboard/user/contests/[contestId]/results/+page.svelte b/src/routes/dashboard/user/contests/[contestId]/results/+page.svelte index 385b516..79ba7b7 100644 --- a/src/routes/dashboard/user/contests/[contestId]/results/+page.svelte +++ b/src/routes/dashboard/user/contests/[contestId]/results/+page.svelte @@ -3,9 +3,7 @@ import * as Table from '$lib/components/ui/table'; import * as Card from '$lib/components/ui/card'; import Trophy from '@lucide/svelte/icons/trophy'; - import Medal from '@lucide/svelte/icons/medal'; import Target from '@lucide/svelte/icons/target'; - import Users from '@lucide/svelte/icons/users'; import CheckCircle from '@lucide/svelte/icons/check-circle'; import { getContestResults } from './results.remote'; import { format } from 'date-fns'; @@ -14,7 +12,6 @@ interface Props { data: { contestId: number; - currentUserId: number; contest: import('$lib/dto/contest').ContestDetailed; }; } @@ -26,53 +23,10 @@ contest: data.contest }); - // Sort leaderboard by total score (sum of best scores across all tasks) - let sortedLeaderboard = $derived.by(() => { - if (!resultsQuery.current?.leaderboard) return []; - - return [...resultsQuery.current.leaderboard] - .map((userStats) => { - const totalScore = userStats.taskBreakdown.reduce((sum, task) => sum + task.bestScore, 0); - return { ...userStats, totalScore }; - }) - .sort((a, b) => b.totalScore - a.totalScore); - }); - - // Find current user's position - let currentUserPosition = $derived.by(() => { - const position = sortedLeaderboard.findIndex((user) => user.user.id === data.currentUserId); - return position >= 0 ? position + 1 : null; - }); - - function getRankBadgeClass(rank: number): string { - if (rank === 1) return 'bg-primary/10 text-primary border-primary/20'; - if (rank === 2) return 'bg-secondary/10 text-secondary border-secondary/20'; - if (rank === 3) return 'bg-accent/10 text-accent-foreground border-accent/20'; - return 'bg-muted text-muted-foreground border-border'; - } - - function getRankIcon(rank: number) { - // Returns Medal component for top 3 ranks, null for rank 4+ - if (rank <= 3) return Medal; - return null; - } - function formatContestDate(dateString: string): string { const date = new Date(dateString); return format(date, 'MMM dd, yyyy'); } - - // Pagination state - let currentPage = $state(1); - let pageSize = $state(10); - - let paginatedLeaderboard = $derived.by(() => { - const start = (currentPage - 1) * pageSize; - const end = start + pageSize; - return sortedLeaderboard.slice(start, end); - }); - - let totalPages = $derived(Math.ceil(sortedLeaderboard.length / pageSize));
@@ -98,342 +52,106 @@ /> {:else if resultsQuery.loading} - {:else if resultsQuery.current} + {:else if resultsQuery.current?.myResults} - {#if resultsQuery.current.myResults} -
- - - - - {m.contest_results_my_total_score()} - - - -

- {resultsQuery.current.myResults.taskResults - .reduce((sum, task) => sum + task.bestScore, 0) - .toFixed(1)} -

-
-
- - - - - - {m.contest_results_tasks_completed()} - - - -

- {resultsQuery.current.myResults.taskResults.filter((t) => t.bestScore === 100).length} - / {resultsQuery.current.myResults.taskResults.length} -

-
-
- - - - - - {m.contest_results_total_submissions()} - - - -

- {resultsQuery.current.myResults.taskResults.reduce( - (sum, task) => sum + task.submissionCount, - 0 - )} -

-
-
- - {#if currentUserPosition} - - - - - {m.contest_results_my_rank()} - - - -

- #{currentUserPosition} -

-
-
- {/if} -
- - +
- - {m.contest_results_my_task_results()} + + + + {m.contest_results_my_total_score()} + -
- - - - {m.contest_results_task()} - - - {m.contest_results_score_submissions()} - - - - {#each resultsQuery.current.myResults.taskResults as taskResult (taskResult.task.id)} - - {taskResult.task.title} - - - - - {taskResult.bestScore.toFixed(1)}% - - / {taskResult.submissionCount} - - - {/each} - - -
+

+ {resultsQuery.current.myResults.taskResults + .reduce((sum, task) => sum + task.bestScore, 0) + .toFixed(1)} +

- {/if} - - {#if sortedLeaderboard.length >= 3} - - - - - {m.contest_results_top_champions()} + + + + + {m.contest_results_tasks_completed()} -
- - {#if sortedLeaderboard[1]} -
-
- -
-
-

- {sortedLeaderboard[1].user.name} - {sortedLeaderboard[1].user.surname} -

-

@{sortedLeaderboard[1].user.username}

-

- {sortedLeaderboard[1].totalScore.toFixed(1)} -

-

- {m.contest_results_solved_partial({ - solved: sortedLeaderboard[1].tasksSolved, - partial: sortedLeaderboard[1].tasksPartiallySolved - })} -

-
-
- {/if} - - - {#if sortedLeaderboard[0]} -
-
- -
-
-

- {sortedLeaderboard[0].user.name} - {sortedLeaderboard[0].user.surname} -

-

@{sortedLeaderboard[0].user.username}

-

- {sortedLeaderboard[0].totalScore.toFixed(1)} -

-

- {m.contest_results_solved_partial({ - solved: sortedLeaderboard[0].tasksSolved, - partial: sortedLeaderboard[0].tasksPartiallySolved - })} -

-
-
- {/if} - - - {#if sortedLeaderboard[2]} -
-
- -
-
-

- {sortedLeaderboard[2].user.name} - {sortedLeaderboard[2].user.surname} -

-

@{sortedLeaderboard[2].user.username}

-

- {sortedLeaderboard[2].totalScore.toFixed(1)} -

-

- {m.contest_results_solved_partial({ - solved: sortedLeaderboard[2].tasksSolved, - partial: sortedLeaderboard[2].tasksPartiallySolved - })} -

-
-
- {/if} -
+

+ {resultsQuery.current.myResults.taskResults.filter((t) => t.bestScore === 100).length} + / {resultsQuery.current.myResults.taskResults.length} +

- {/if} - - {#if sortedLeaderboard.length > 0} - -
- - - {m.contest_results_leaderboard()} - -

- {m.contest_results_participants({ count: sortedLeaderboard.length })} -

-
+ + + + {m.contest_results_total_submissions()} + -
- - - - {m.contest_results_rank()} - {m.contest_results_name()} - - {m.contest_results_total_score()} - - - - - - {#each paginatedLeaderboard as userStats, index (userStats.user.id)} - {@const rank = (currentPage - 1) * pageSize + index + 1} - {@const RankIcon = getRankIcon(rank)} - {@const isCurrentUser = userStats.user.id === data.currentUserId} - - -
- {#if RankIcon} -
- -
- {:else} - {rank} - {/if} -
-
- - {userStats.user.name} - {userStats.user.surname} - {#if isCurrentUser} - {m.contest_results_you()} - {/if} - - - - {userStats.totalScore.toFixed(1)} - - - -
- {/each} -
-
-
- - - {#if totalPages > 1} -
-

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

-
- - - {m.contest_results_page_info({ current: currentPage, total: totalPages })} - - -
-
- {/if} +

+ {resultsQuery.current.myResults.taskResults.reduce( + (sum, task) => sum + task.submissionCount, + 0 + )} +

- {:else} - - {/if} +
+ + + + + {m.contest_results_my_task_results()} + + +
+ + + + {m.contest_results_task()} + + + {m.contest_results_score_submissions()} + + + + {#each resultsQuery.current.myResults.taskResults as taskResult (taskResult.task.id)} + + {taskResult.task.title} + + + + + {taskResult.bestScore.toFixed(1)}% + + / {taskResult.submissionCount} + + + {/each} + + +
+
+
+ {:else} + {/if}
diff --git a/src/routes/dashboard/user/contests/[contestId]/results/results.remote.ts b/src/routes/dashboard/user/contests/[contestId]/results/results.remote.ts index cde1e05..348450f 100644 --- a/src/routes/dashboard/user/contests/[contestId]/results/results.remote.ts +++ b/src/routes/dashboard/user/contests/[contestId]/results/results.remote.ts @@ -1,10 +1,9 @@ import { query, getRequestEvent } from '$app/server'; -import { createContestsManagementService } from '$lib/services/ContestsManagementService'; import { createContestService } from '$lib/services/ContestService'; import { ApiError } from '$lib/services/ApiService'; import { error } from '@sveltejs/kit'; import * as v from 'valibot'; -import type { UserContestStats, ContestResults, ContestDetailed } from '$lib/dto/contest'; +import type { ContestResults, ContestDetailed } from '$lib/dto/contest'; import { ContestStatus } from '$lib/dto/contest'; import * as m from '$lib/paraglide/messages'; @@ -34,23 +33,17 @@ export const getContestResults = query( contest: ContestDetailed; }): Promise<{ contest: ContestDetailed; - leaderboard: UserContestStats[]; myResults: ContestResults; }> => { const { cookies } = getRequestEvent(); try { - const contestsManagementService = createContestsManagementService(cookies); const contestService = createContestService(cookies); - const [leaderboard, myResults] = await Promise.all([ - contestsManagementService.getUserStats(params.contestId), - contestService.getMyResults(params.contestId) - ]); + const myResults = await contestService.getMyResults(params.contestId); return { contest: params.contest, - leaderboard, myResults }; } catch (err) {