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:

+ + + + + + + + + + + +
#UsernameTossups ReadBonuses ReadTotal
+
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;