Skip to content

Commit ae368bf

Browse files
committed
feat: add reviewers leaderboard
1 parent 6c065b5 commit ae368bf

File tree

3 files changed

+199
-6
lines changed

3 files changed

+199
-6
lines changed

lark-ui/src/routes/admin/+page.svelte

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,17 @@ type AdminMetrics = {
8383
totalSubmittedHackatimeHours: number;
8484
};
8585
86+
type ReviewerStats = {
87+
reviewerId: string;
88+
firstName: string | null;
89+
lastName: string | null;
90+
email: string | null;
91+
approved: number;
92+
rejected: number;
93+
total: number;
94+
lastReviewedAt: string | null;
95+
};
96+
8697
type Tab = 'submissions' | 'projects' | 'users' | 'shop' | 'giftcodes';
8798
type StatusFilter = 'all' | 'pending' | 'approved' | 'rejected';
8899
type SortField = 'createdAt' | 'projectTitle' | 'userName' | 'approvalStatus' | 'nowHackatimeHours' | 'approvedHours';
@@ -297,6 +308,10 @@ let giftCodeError = $state('');
297308
let giftCodeSuccess = $state('');
298309
let giftCodeResults = $state<Array<{ email: string; code: string; success: boolean; error?: string }>>([]);
299310
311+
let reviewerLeaderboard = $state<ReviewerStats[]>([]);
312+
let leaderboardLoading = $state(false);
313+
let leaderboardLoaded = $state(false);
314+
300315
let submissionDrafts = $state<Record<number, { approvalStatus: string; approvedHours: string; userFeedback: string; hoursJustification: string; sendEmailNotification: boolean }>>(
301316
buildSubmissionDrafts(data.submissions ?? [])
302317
);
@@ -798,9 +813,13 @@ async function recalculateAllProjectsHours() {
798813
}
799814
800815
submissionSuccess = { ...submissionSuccess, [submission.submissionId]: 'Submission quick approved and synced to Airtable' };
816+
817+
const currentSubmissionId = submission.submissionId;
801818
await loadSubmissions();
802819
await loadProjects();
803820
await loadMetrics();
821+
822+
advanceToNextSubmission(currentSubmissionId);
804823
} catch (err) {
805824
submissionErrors = {
806825
...submissionErrors,
@@ -817,9 +836,10 @@ async function recalculateAllProjectsHours() {
817836
approvalStatus: 'rejected',
818837
approvedHours: '0',
819838
};
820-
// Quick Deny always sends email if the toggle is on, or uses toggle state
821839
const shouldSendEmail = submissionDrafts[submissionId].sendEmailNotification;
822840
await saveSubmission(submissionId, shouldSendEmail);
841+
842+
advanceToNextSubmission(submissionId);
823843
}
824844
825845
async function recalculateSubmissionHours(submissionId: number, projectId: number) {
@@ -962,6 +982,48 @@ async function recalculateAllProjectsHours() {
962982
}
963983
}
964984
985+
async function loadReviewerLeaderboard() {
986+
leaderboardLoading = true;
987+
try {
988+
const response = await fetch(`${apiUrl}/api/admin/reviewer-leaderboard`, {
989+
credentials: 'include',
990+
});
991+
if (response.ok) {
992+
reviewerLeaderboard = await response.json();
993+
leaderboardLoaded = true;
994+
}
995+
} catch (err) {
996+
console.error('Failed to load reviewer leaderboard:', err);
997+
} finally {
998+
leaderboardLoading = false;
999+
}
1000+
}
1001+
1002+
function getNextPendingSubmission(currentSubmissionId: number): { projectId: number; submissionId: number } | null {
1003+
const projectIds = Object.keys(filteredGroupedSubmissions).map(Number);
1004+
1005+
for (const projectId of projectIds) {
1006+
const projectSubmissions = filteredGroupedSubmissions[projectId];
1007+
for (const submission of projectSubmissions) {
1008+
if (submission.submissionId !== currentSubmissionId && submission.approvalStatus === 'pending') {
1009+
return { projectId, submissionId: submission.submissionId };
1010+
}
1011+
}
1012+
}
1013+
return null;
1014+
}
1015+
1016+
function advanceToNextSubmission(currentSubmissionId: number) {
1017+
const next = getNextPendingSubmission(currentSubmissionId);
1018+
if (next) {
1019+
selectedSubmissionByProject = { ...selectedSubmissionByProject, [next.projectId]: next.submissionId };
1020+
const element = document.getElementById(`submission-card-${next.projectId}`);
1021+
if (element) {
1022+
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
1023+
}
1024+
}
1025+
}
1026+
9651027
async function showGiftCodesTab() {
9661028
activeTab = 'giftcodes';
9671029
if (!giftCodesLoaded && !giftCodesLoading) {
@@ -1525,6 +1587,63 @@ function normalizeUrl(url: string | null): string | null {
15251587
</div>
15261588
</div>
15271589

1590+
<div class="rounded-2xl border border-gray-700 bg-gray-900/70 backdrop-blur p-6 space-y-4">
1591+
<div class="flex items-center justify-between">
1592+
<h3 class="text-lg font-semibold flex items-center gap-2">
1593+
🏆 Reviewer Leaderboard
1594+
</h3>
1595+
<button
1596+
class="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg border border-gray-700 transition-colors text-sm"
1597+
onclick={loadReviewerLeaderboard}
1598+
disabled={leaderboardLoading}
1599+
>
1600+
{leaderboardLoading ? 'Loading...' : (leaderboardLoaded ? 'Refresh' : 'Load Leaderboard')}
1601+
</button>
1602+
</div>
1603+
1604+
{#if leaderboardLoaded && reviewerLeaderboard.length > 0}
1605+
<div class="overflow-x-auto">
1606+
<table class="w-full">
1607+
<thead class="bg-gray-800/50">
1608+
<tr>
1609+
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">#</th>
1610+
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Reviewer</th>
1611+
<th class="px-4 py-3 text-center text-sm font-semibold text-green-400">Approved</th>
1612+
<th class="px-4 py-3 text-center text-sm font-semibold text-red-400">Rejected</th>
1613+
<th class="px-4 py-3 text-center text-sm font-semibold text-purple-400">Total</th>
1614+
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Last Review</th>
1615+
</tr>
1616+
</thead>
1617+
<tbody class="divide-y divide-gray-700">
1618+
{#each reviewerLeaderboard as reviewer, index}
1619+
<tr class="hover:bg-gray-800/30 {index === 0 ? 'bg-yellow-500/10' : index === 1 ? 'bg-gray-400/10' : index === 2 ? 'bg-amber-600/10' : ''}">
1620+
<td class="px-4 py-3 text-sm font-bold {index === 0 ? 'text-yellow-400' : index === 1 ? 'text-gray-300' : index === 2 ? 'text-amber-500' : 'text-gray-400'}">
1621+
{#if index === 0}🥇{:else if index === 1}🥈{:else if index === 2}🥉{:else}{index + 1}{/if}
1622+
</td>
1623+
<td class="px-4 py-3">
1624+
<p class="text-sm font-medium text-white">
1625+
{reviewer.firstName || ''} {reviewer.lastName || ''}
1626+
</p>
1627+
<p class="text-xs text-gray-400">{reviewer.email || `ID: ${reviewer.reviewerId}`}</p>
1628+
</td>
1629+
<td class="px-4 py-3 text-center text-sm font-semibold text-green-400">{reviewer.approved}</td>
1630+
<td class="px-4 py-3 text-center text-sm font-semibold text-red-400">{reviewer.rejected}</td>
1631+
<td class="px-4 py-3 text-center text-sm font-bold text-purple-400">{reviewer.total}</td>
1632+
<td class="px-4 py-3 text-sm text-gray-400">
1633+
{reviewer.lastReviewedAt ? formatDate(reviewer.lastReviewedAt) : ''}
1634+
</td>
1635+
</tr>
1636+
{/each}
1637+
</tbody>
1638+
</table>
1639+
</div>
1640+
{:else if leaderboardLoaded}
1641+
<p class="text-gray-400 text-sm">No reviews recorded yet.</p>
1642+
{:else}
1643+
<p class="text-gray-500 text-sm">Click "Load Leaderboard" to see reviewer stats.</p>
1644+
{/if}
1645+
</div>
1646+
15281647
{#if submissionsLoading}
15291648
<div class="py-12 text-center text-gray-300">Loading submissions...</div>
15301649
{:else if Object.keys(filteredGroupedSubmissions).length === 0}
@@ -1533,11 +1652,11 @@ function normalizeUrl(url: string | null): string | null {
15331652
</div>
15341653
{:else}
15351654
<div class="grid gap-6">
1536-
{#each Object.entries(filteredGroupedSubmissions) as [projectIdStr, projectSubmissions]}
1537-
{@const projectId = Number(projectIdStr)}
1538-
{@const selectedSubmissionId = selectedSubmissionByProject[projectId] ?? projectSubmissions[0].submissionId}
1539-
{@const selectedSubmission = projectSubmissions.find((s: AdminSubmission) => s.submissionId === selectedSubmissionId) ?? projectSubmissions[0]}
1540-
<div class="rounded-2xl border border-gray-700 bg-gray-900/70 backdrop-blur p-6 space-y-4">
1655+
{#each Object.entries(filteredGroupedSubmissions) as [projectIdStr, projectSubmissions]}
1656+
{@const projectId = Number(projectIdStr)}
1657+
{@const selectedSubmissionId = selectedSubmissionByProject[projectId] ?? projectSubmissions[0].submissionId}
1658+
{@const selectedSubmission = projectSubmissions.find((s: AdminSubmission) => s.submissionId === selectedSubmissionId) ?? projectSubmissions[0]}
1659+
<div id="submission-card-{projectId}" class="rounded-2xl border border-gray-700 bg-gray-900/70 backdrop-blur p-6 space-y-4">
15411660
{#if projectSubmissions.length > 1}
15421661
<div class="mb-4 pb-4 border-b border-gray-700">
15431662
<h4 class="text-sm font-semibold uppercase tracking-wide text-gray-400 mb-3">Submissions ({projectSubmissions.length})</h4>

owl-api/src/admin/admin.controller.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,11 @@ export class AdminController {
120120
async getTotals() {
121121
return this.adminService.getTotals();
122122
}
123+
124+
@Get('reviewer-leaderboard')
125+
@UseGuards(RolesGuard)
126+
@Roles(Role.Admin)
127+
async getReviewerLeaderboard() {
128+
return this.adminService.getReviewerLeaderboard();
129+
}
123130
}

owl-api/src/admin/admin.service.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,73 @@ export class AdminService {
873873
return users;
874874
}
875875

876+
async getReviewerLeaderboard() {
877+
const reviewedSubmissions = await this.prisma.submission.findMany({
878+
where: {
879+
reviewedBy: { not: null },
880+
approvalStatus: { in: ['approved', 'rejected'] },
881+
},
882+
select: {
883+
reviewedBy: true,
884+
approvalStatus: true,
885+
reviewedAt: true,
886+
},
887+
});
888+
889+
const reviewerStats = new Map<string, { approved: number; rejected: number; total: number; lastReviewedAt: Date | null }>();
890+
891+
for (const submission of reviewedSubmissions) {
892+
if (!submission.reviewedBy) continue;
893+
894+
const stats = reviewerStats.get(submission.reviewedBy) || { approved: 0, rejected: 0, total: 0, lastReviewedAt: null };
895+
896+
if (submission.approvalStatus === 'approved') {
897+
stats.approved++;
898+
} else if (submission.approvalStatus === 'rejected') {
899+
stats.rejected++;
900+
}
901+
stats.total++;
902+
903+
if (submission.reviewedAt && (!stats.lastReviewedAt || submission.reviewedAt > stats.lastReviewedAt)) {
904+
stats.lastReviewedAt = submission.reviewedAt;
905+
}
906+
907+
reviewerStats.set(submission.reviewedBy, stats);
908+
}
909+
910+
const reviewerUserIds = Array.from(reviewerStats.keys()).map(id => parseInt(id)).filter(id => !isNaN(id));
911+
912+
const reviewerUsers = await this.prisma.user.findMany({
913+
where: { userId: { in: reviewerUserIds } },
914+
select: {
915+
userId: true,
916+
firstName: true,
917+
lastName: true,
918+
email: true,
919+
},
920+
});
921+
922+
const userMap = new Map(reviewerUsers.map(u => [u.userId.toString(), u]));
923+
924+
const leaderboard = Array.from(reviewerStats.entries()).map(([reviewerId, stats]) => {
925+
const user = userMap.get(reviewerId);
926+
return {
927+
reviewerId,
928+
firstName: user?.firstName || null,
929+
lastName: user?.lastName || null,
930+
email: user?.email || null,
931+
approved: stats.approved,
932+
rejected: stats.rejected,
933+
total: stats.total,
934+
lastReviewedAt: stats.lastReviewedAt,
935+
};
936+
});
937+
938+
leaderboard.sort((a, b) => b.total - a.total);
939+
940+
return leaderboard;
941+
}
942+
876943
private async recalculateProjectInternal(
877944
project: {
878945
projectId: number;

0 commit comments

Comments
 (0)