From e192f8acd162969aa937baa6b2be9ca1b39a3f7a Mon Sep 17 00:00:00 2001 From: Lawrence Tong Date: Tue, 18 Aug 2026 16:03:46 -0500 Subject: [PATCH] fix: correct three defects in the account leaderboard aggregation These are independent of any new endpoint. All three affect /api/admin/leaderboard and client/admin/leaderboard as they stand today. routes/api/admin/leaderboard.js passes limit = null when ?limit is absent, and slice(0, null) coerces the end index to 0, so the admin leaderboard currently returns an empty array. A falsy limit now returns every row; a real limit behaves as before. A user deleted since their buzzes were recorded misses the $lookup, so $arrayElemAt yields no username. mergeTwoSortedArrays compares usernames with < and >, and both comparisons are false against a missing username, so every deleted user is treated as the same person. Where the two inputs hold unequal numbers of such rows the merge desynchronises: a deleted user absorbs a live user's bonus counts, and that user's own row is left understating them. These rows are now dropped before the merge. The inputs were ordered by a $sort stage, which compares strings by their UTF-8 bytes, while the merge compares UTF-16 code units. The two disagree for characters outside the Basic Multilingual Plane, which UTF-8 orders after every BMP character but UTF-16 orders among the surrogates, ahead of U+E000 through U+FFFF. When such a user appears in one input but not the other, the merge emits them as two rows instead of one. Both inputs are now sorted in JS with the same comparison the merge uses, which also removes an unindexed server-side sort. Co-Authored-By: Claude Opus 5 --- database/account-info/leaderboard.js | 31 +++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/database/account-info/leaderboard.js b/database/account-info/leaderboard.js index acf8fea7b..eab6393bc 100644 --- a/database/account-info/leaderboard.js +++ b/database/account-info/leaderboard.js @@ -5,9 +5,14 @@ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes let cachedOverall = null; let cachedAt = 0; +/** + * Rank every user by the number of questions they have heard. + * @param {Number} [limit] - the maximum number of rows to return; every row if falsy. + * @returns {Promise} sorted from most to least questions heard. + */ export default async function leaderboard (limit) { if (cachedOverall && Date.now() - cachedAt < CACHE_TTL) { - return cachedOverall.slice(0, limit); + return limit ? cachedOverall.slice(0, limit) : cachedOverall; } const tossupLeaderboard = await helper('tossup'); @@ -22,7 +27,21 @@ export default async function leaderboard (limit) { overall.sort((a, b) => b.total - a.total); cachedOverall = overall; cachedAt = Date.now(); - return overall.slice(0, limit); + return limit ? overall.slice(0, limit) : overall; +} + +/** + * mergeTwoSortedArrays compares usernames with < and >, so both of its inputs + * must be ordered by that same comparison. A $sort stage orders strings by + * their UTF-8 bytes instead, which disagrees with < and > outside ASCII. + * @param {Object} a + * @param {Object} b + * @returns {Number} + */ +function byUsername (a, b) { + if (a.username < b.username) { return -1; } + if (a.username > b.username) { return 1; } + return 0; } /** @@ -55,17 +74,19 @@ async function helper (type = 'tossup') { total: '$count' } }, - { $sort: { username: 1 } } + // users deleted since their buzzes were recorded have no username to rank, + // and a null username compares as equal to every other one in the merge + { $match: { username: { $ne: null } } } ]; switch (type) { case 'tossup': { const results = await perTossupData.aggregate(aggregation).toArray(); - return results.map((result) => ({ ...result, tossupCount: result.total, bonusCount: 0 })); + return results.map((result) => ({ ...result, tossupCount: result.total, bonusCount: 0 })).sort(byUsername); } case 'bonus': { const results = await perBonusData.aggregate(aggregation).toArray(); - return results.map((result) => ({ ...result, tossupCount: 0, bonusCount: result.total })); + return results.map((result) => ({ ...result, tossupCount: 0, bonusCount: result.total })).sort(byUsername); } } }