diff --git a/client/admin/index.html b/client/admin/index.html
index b712b93d..82ea375b 100644
--- a/client/admin/index.html
+++ b/client/admin/index.html
@@ -74,6 +74,7 @@
diff --git a/client/admin/leaderboard/index.html b/client/admin/leaderboard/index.html
new file mode 100644
index 00000000..745b56d5
--- /dev/null
+++ b/client/admin/leaderboard/index.html
@@ -0,0 +1,90 @@
+
+
+
+
+ QB Reader
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Most active QB Reader players:
+
+
+
+ # |
+ Username |
+ Tossups Read |
+ Bonuses Read |
+ Total |
+
+
+
+
+
Loading...
+
+
+
+
+
+
+
+
+
diff --git a/client/admin/leaderboard/index.js b/client/admin/leaderboard/index.js
new file mode 100644
index 00000000..ee77300e
--- /dev/null
+++ b/client/admin/leaderboard/index.js
@@ -0,0 +1,23 @@
+import sortTable from '../../scripts/utilities/tables.js';
+
+const limit = 20;
+fetch('/api/admin/leaderboard?' + new URLSearchParams({ limit }))
+ .then(res => res.json())
+ .then(data => data.data)
+ .then(data => {
+ document.getElementById('spinner').classList.add('d-none');
+ const table = document.getElementById('leaderboard');
+ data.forEach((document, index) => {
+ const row = table.insertRow(-1);
+ row.insertCell(-1).textContent = index + 1;
+ row.insertCell(-1).textContent = document.username;
+ row.insertCell(-1).textContent = document.tossupCount;
+ row.insertCell(-1).textContent = document.bonusCount;
+ row.insertCell(-1).textContent = document.total;
+ });
+ });
+
+document.querySelectorAll('th').forEach((th, index) => {
+ const numeric = [true, false, true, true, true];
+ th.addEventListener('click', () => sortTable(index, numeric[index], 'leaderboard', 0, 0));
+});
diff --git a/client/scripts/utilities/tables.js b/client/scripts/utilities/tables.js
new file mode 100644
index 00000000..13ba079d
--- /dev/null
+++ b/client/scripts/utilities/tables.js
@@ -0,0 +1,78 @@
+/**
+ * Sorts a table by the values in a specified column.
+ * @param {number} n - a zero-indexed column number to sort
+ * @param {boolean} numeric - whether the column values represent numeric values
+ * @param {string} tableId - the id of the table to sort
+ * @param {number} headers - the number of headers of the table to skip (default 1)
+ * @param {number} footers - the number of footers of the table to skip (default 0)
+ */
+export default function sortTable (n, numeric = false, tableId = 'table', headers = 1, footers = 0) {
+ let rows; let switching; let i; let x; let y; let shouldSwitch; let dir; let switchcount = 0;
+ const table = document.getElementById(tableId);
+ switching = true;
+ // Set the sorting direction to ascending for text;
+ // Numerals are always sorted in the opposite order from text
+ dir = 'asc';
+ /* Make a loop that will continue until
+ no switching has been done: */
+ while (switching) {
+ // Start by saying: no switching is done:
+ switching = false;
+ rows = table.rows;
+ // Loop through all table rows (except headers and footers)
+ for (i = headers; i < rows.length - 1 - footers; i++) {
+ // Start by saying there should be no switching:
+ shouldSwitch = false;
+ /* Get the two elements you want to compare,
+ one from current row and one from the next: */
+ x = rows[i].children[n];
+ y = rows[i + 1].children[n];
+ /* Check if the two rows should switch place,
+ based on the direction, asc or desc: */
+ if (dir === 'asc') {
+ if (numeric) {
+ if (parseFloat(x.innerHTML) < parseFloat(y.innerHTML)) {
+ // If so, mark as a switch and break the loop:
+ shouldSwitch = true;
+ break;
+ }
+ } else {
+ if (x.innerHTML.toLowerCase() > y.innerHTML.toLowerCase()) {
+ // If so, mark as a switch and break the loop:
+ shouldSwitch = true;
+ break;
+ }
+ }
+ } else if (dir === 'desc') {
+ if (numeric) {
+ if (parseFloat(x.innerHTML) > parseFloat(y.innerHTML)) {
+ // If so, mark as a switch and break the loop:
+ shouldSwitch = true;
+ break;
+ }
+ } else {
+ if (x.innerHTML.toLowerCase() < y.innerHTML.toLowerCase()) {
+ // If so, mark as a switch and break the loop:
+ shouldSwitch = true;
+ break;
+ }
+ }
+ }
+ }
+ if (shouldSwitch) {
+ /* If a switch has been marked, make the switch
+ and mark that a switch has been done: */
+ rows[i].parentNode.insertBefore(rows[i + 1], rows[i]);
+ switching = true;
+ // Each time a switch is done, increase this count by 1:
+ switchcount++;
+ } else {
+ /* If no switching has been done AND the direction is "asc",
+ set the direction to "desc" and run the while loop again. */
+ if (switchcount === 0 && dir === 'asc') {
+ dir = 'desc';
+ switching = true;
+ }
+ }
+ }
+}
diff --git a/database/account-info/leaderboard.js b/database/account-info/leaderboard.js
new file mode 100644
index 00000000..52c5b810
--- /dev/null
+++ b/database/account-info/leaderboard.js
@@ -0,0 +1,59 @@
+import { tossupData, bonusData } from '../../database/qbreader/collections.js';
+import mergeTwoSortedArrays from '../../merge-two-sorted-arrays.js';
+
+export default async function leaderboard (limit) {
+ const tossupLeaderboard = await helper('tossup');
+ const bonusLeaderboard = await helper('bonus');
+ const overall = mergeTwoSortedArrays(
+ tossupLeaderboard,
+ bonusLeaderboard,
+ (document) => document.username,
+ (document1, document2) => ({ _id: document1._id, username: document1.username, tossupCount: document1.tossupCount, bonusCount: document2.bonusCount, total: document1.total + document2.total })
+ );
+ // sort from most to least
+ overall.sort((a, b) => b.total - a.total);
+ return overall.slice(0, limit);
+}
+
+/**
+ *
+ * @param {'tossup' | 'bonus'} type - the type of questions to filter by
+ * @returns
+ */
+async function helper (type = 'tossup') {
+ const aggregation = [
+ {
+ $group: {
+ _id: '$user_id',
+ count: { $sum: 1 }
+ }
+ },
+ {
+ $lookup: {
+ from: 'users',
+ localField: '_id',
+ foreignField: '_id',
+ as: 'user'
+ }
+ },
+ {
+ $project: {
+ username: { $arrayElemAt: ['$user.username', 0] },
+ _id: 1,
+ total: '$count'
+ }
+ },
+ { $sort: { username: 1 } }
+ ];
+
+ switch (type) {
+ case 'tossup': {
+ const results = await tossupData.aggregate(aggregation).toArray();
+ return results.map((result) => ({ ...result, tossupCount: result.total, bonusCount: 0 }));
+ }
+ case 'bonus': {
+ const results = await bonusData.aggregate(aggregation).toArray();
+ return results.map((result) => ({ ...result, tossupCount: 0, bonusCount: result.total }));
+ }
+ }
+}
diff --git a/routes/admin/index.js b/routes/admin/index.js
index 66ea4fa3..051821f2 100644
--- a/routes/admin/index.js
+++ b/routes/admin/index.js
@@ -3,6 +3,7 @@ import { checkToken } from '../../server/authentication.js';
import categoryReportsRouter from './category-reports.js';
import geowordRouter from './geoword.js';
+import leaderboardRouter from './leaderboard.js';
import { Router } from 'express';
@@ -29,6 +30,7 @@ router.use(async (req, res, next) => {
router.use('/category-reports', categoryReportsRouter);
router.use('/geoword', geowordRouter);
+router.use('/leaderboard', leaderboardRouter);
router.get('/', (req, res) => {
res.sendFile('index.html', { root: './client/admin' });
diff --git a/routes/admin/leaderboard.js b/routes/admin/leaderboard.js
new file mode 100644
index 00000000..46fcbd80
--- /dev/null
+++ b/routes/admin/leaderboard.js
@@ -0,0 +1,9 @@
+import { Router } from 'express';
+
+const router = Router();
+
+router.get('/', async (req, res) => {
+ res.sendFile('index.html', { root: './client/admin/leaderboard' });
+});
+
+export default router;
diff --git a/routes/api/admin/index.js b/routes/api/admin/index.js
index 22a0e894..15459798 100644
--- a/routes/api/admin/index.js
+++ b/routes/api/admin/index.js
@@ -1,5 +1,6 @@
import geowordRouter from './geoword.js';
import listReportsRouter from './list-reports.js';
+import leaderboardRouter from './leaderboard.js';
import updateSubcategoryRouter from './update-subcategory.js';
import isAdmin from '../../../database/account-info/is-admin.js';
@@ -28,6 +29,7 @@ router.use(async (req, res, next) => {
router.use('/geoword', geowordRouter);
router.use('/list-reports', listReportsRouter);
+router.use('/leaderboard', leaderboardRouter);
router.use('/update-subcategory', updateSubcategoryRouter);
export default router;
diff --git a/routes/api/admin/leaderboard.js b/routes/api/admin/leaderboard.js
new file mode 100644
index 00000000..d2b236f8
--- /dev/null
+++ b/routes/api/admin/leaderboard.js
@@ -0,0 +1,13 @@
+import leaderboard from '../../../database/account-info/leaderboard.js';
+
+import { Router } from 'express';
+
+const router = Router();
+
+router.get('/', async (req, res) => {
+ const limit = req.query.limit ? parseInt(req.query.limit) : null;
+ const data = await leaderboard(limit);
+ res.json({ data });
+});
+
+export default router;