diff --git a/src/controllers/dashboard.controller.js b/src/controllers/dashboard.controller.js index 1e32004..5eb8615 100644 --- a/src/controllers/dashboard.controller.js +++ b/src/controllers/dashboard.controller.js @@ -34,49 +34,44 @@ const getDashboard = asyncHandler(async (req, res) => { }, }); - // Get today's status for each membership - const today = new Date(); - today.setHours(0, 0, 0, 0); + if (memberships.length === 0) { + return res.status(200).json({ success: true, data: [] }); + } - const dashboardData = await Promise.all( - memberships.map(async (membership) => { - // Get today's result - const todayResult = await prisma.dailyResult.findUnique({ - where: { - challengeId_memberId_date: { - challengeId: membership.challengeId, - memberId: membership.id, - date: today, - }, - }, - }); + const memberIds = memberships.map((m) => m.id); - // Get recent daily results (last 7 days) - const recentResults = await evaluationService.getMemberDailyResults( - membership.id, - 7 - ); + // Delegate all bulk fetching and grouping to the service layer. + // getBulkMemberDailyResults uses a 7-calendar-day window (not take:7) so + // that missing days are correctly represented as absent entries in the + // activity strip rather than being silently skipped. + const [todayResultByMember, recentResultsByMember] = await Promise.all([ + evaluationService.getBulkTodayResults(memberIds), + evaluationService.getBulkMemberDailyResults(memberIds, 7), + ]); - return { - challenge: membership.challenge, - currentStreak: membership.currentStreak, - longestStreak: membership.longestStreak, - totalPenalties: membership.totalPenalties, - todayStatus: todayResult - ? { - completed: todayResult.completed, - submissionsCount: todayResult.submissionsCount, - evaluatedAt: todayResult.evaluatedAt, - } - : null, - recentResults: recentResults.map((r) => ({ - date: r.date, - completed: r.completed, - submissionsCount: r.submissionsCount, - })), - }; - }) - ); + const dashboardData = memberships.map((membership) => { + const todayResult = todayResultByMember[membership.id] || null; + const memberRecentResults = recentResultsByMember[membership.id] || []; + + return { + challenge: membership.challenge, + currentStreak: membership.currentStreak, + longestStreak: membership.longestStreak, + totalPenalties: membership.totalPenalties, + todayStatus: todayResult + ? { + completed: todayResult.completed, + submissionsCount: todayResult.submissionsCount, + evaluatedAt: todayResult.evaluatedAt, + } + : null, + recentResults: memberRecentResults.map((r) => ({ + date: r.date, + completed: r.completed, + submissionsCount: r.submissionsCount, + })), + }; + }); res.status(200).json({ success: true, @@ -187,30 +182,30 @@ const getChallengeLeaderboard = asyncHandler(async (req, res) => { ], }); - // Get total completed days for each member - const leaderboard = await Promise.all( - members.map(async (member) => { - const results = await prisma.dailyResult.findMany({ - where: { memberId: member.id }, - }); - - const totalDays = results.length; - const completedDays = results.filter((r) => r.completed).length; - const completionRate = - totalDays > 0 ? (completedDays / totalDays) * 100 : 0; - - return { - username: member.user.username, - leetcodeUsername: member.user.leetcodeUsername, - currentStreak: member.currentStreak, - longestStreak: member.longestStreak, - totalPenalties: member.totalPenalties, - completedDays, - totalDays, - completionRate: completionRate.toFixed(2), - }; - }) - ); + if (members.length === 0) { + return res.status(200).json({ success: true, data: [] }); + } + + const memberIds = members.map((m) => m.id); + + // Delegate bulk fetching to the service layer — one query for all members + const statsByMember = await evaluationService.getBulkAllMemberResults(memberIds); + + const leaderboard = members.map((member) => { + const { totalDays = 0, completedDays = 0 } = statsByMember[member.id] || {}; + const completionRate = totalDays > 0 ? (completedDays / totalDays) * 100 : 0; + + return { + username: member.user.username, + leetcodeUsername: member.user.leetcodeUsername, + currentStreak: member.currentStreak, + longestStreak: member.longestStreak, + totalPenalties: member.totalPenalties, + completedDays, + totalDays, + completionRate: completionRate.toFixed(2), + }; + }); res.status(200).json({ success: true, @@ -224,8 +219,6 @@ const getChallengeLeaderboard = asyncHandler(async (req, res) => { */ const getTodayStatus = asyncHandler(async (req, res) => { const userId = req.user.id; - const today = new Date(); - today.setHours(0, 0, 0, 0); // Get all active memberships const memberships = await prisma.challengeMember.findMany({ @@ -247,34 +240,37 @@ const getTodayStatus = asyncHandler(async (req, res) => { }, }); - // Get today's results - const todayStatuses = await Promise.all( - memberships.map(async (membership) => { - const result = await prisma.dailyResult.findUnique({ - where: { - challengeId_memberId_date: { - challengeId: membership.challengeId, - memberId: membership.id, - date: today, - }, - }, - }); - - return { - challengeId: membership.challenge.id, - challengeName: membership.challenge.name, - requiredSubmissions: membership.challenge.minSubmissionsPerDay, - status: result - ? { - completed: result.completed, - submissionsCount: result.submissionsCount, - problemsSolved: result.problemsSolved, - evaluatedAt: result.evaluatedAt, - } - : null, - }; - }) - ); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (memberships.length === 0) { + return res.status(200).json({ + success: true, + data: { date: today, challenges: [] }, + }); + } + + const memberIds = memberships.map((m) => m.id); + + // Delegate bulk fetching and grouping to the service layer + const resultByMemberId = await evaluationService.getBulkTodayResults(memberIds); + + const todayStatuses = memberships.map((membership) => { + const result = resultByMemberId[membership.id] || null; + return { + challengeId: membership.challenge.id, + challengeName: membership.challenge.name, + requiredSubmissions: membership.challenge.minSubmissionsPerDay, + status: result + ? { + completed: result.completed, + submissionsCount: result.submissionsCount, + problemsSolved: result.problemsSolved, + evaluatedAt: result.evaluatedAt, + } + : null, + }; + }); res.status(200).json({ success: true, diff --git a/src/services/evaluation.service.js b/src/services/evaluation.service.js index ced9e30..ec7eae6 100644 --- a/src/services/evaluation.service.js +++ b/src/services/evaluation.service.js @@ -316,14 +316,109 @@ const getMemberDailyResults = async (memberId, limit = 30) => { }); }; +/** + * Normalise a Date to local midnight so all date comparisons are consistent. + * Uses local time to match the convention used throughout the codebase + * (stats.service.js, dashboard.controller.js, cron evaluation). + * @param {Date} [date=new Date()] - Date to normalise (defaults to now) + * @returns {Date} Normalised date at 00:00:00.000 local time + */ +const normaliseToMidnight = (date = new Date()) => { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +}; + +/** + * Get daily results for multiple members in a single bulk query. + * Results are returned grouped by memberId to avoid N+1 query patterns. + * + * The window is intentionally calendar-based (e.g. daysBack=7 means the last + * 7 calendar days including today). This is the correct semantic for dashboard + * activity strips where missing days are meaningful — they represent days on + * which the member did not submit. Using a record-count limit (take: N) would + * silently skip missed days and surface stale results from weeks ago. + * + * @param {string[]} memberIds - Array of challenge member IDs + * @param {number} daysBack - Number of calendar days to look back (default 7) + * @returns {Object} Map of memberId -> dailyResult[], ordered newest-first + */ +const getBulkMemberDailyResults = async (memberIds, daysBack = 7) => { + if (!memberIds || memberIds.length === 0) return {}; + + const since = normaliseToMidnight(); + since.setDate(since.getDate() - (daysBack - 1)); + + const results = await prisma.dailyResult.findMany({ + where: { + memberId: { in: memberIds }, + date: { gte: since }, + }, + orderBy: { date: "desc" }, + }); + + return results.reduce((acc, result) => { + if (!acc[result.memberId]) acc[result.memberId] = []; + acc[result.memberId].push(result); + return acc; + }, {}); +}; + +/** + * Get all daily results for multiple members in a single bulk query. + * Returns aggregated stats (totalDays, completedDays) grouped by memberId, + * used by the leaderboard which needs full-history counts rather than a + * calendar window. + * @param {string[]} memberIds - Array of challenge member IDs + * @returns {Object} Map of memberId -> { totalDays, completedDays } + */ +const getBulkAllMemberResults = async (memberIds) => { + if (!memberIds || memberIds.length === 0) return {}; + + const results = await prisma.dailyResult.findMany({ + where: { memberId: { in: memberIds } }, + select: { memberId: true, completed: true }, + }); + + return results.reduce((acc, result) => { + if (!acc[result.memberId]) acc[result.memberId] = { totalDays: 0, completedDays: 0 }; + acc[result.memberId].totalDays += 1; + if (result.completed) acc[result.memberId].completedDays += 1; + return acc; + }, {}); +}; + +/** + * Get today's daily result for multiple members in a single bulk query. + * Results are returned as a map keyed by memberId for O(1) lookup. + * @param {string[]} memberIds - Array of challenge member IDs + * @returns {Object} Map of memberId -> dailyResult (or undefined if none) + */ +const getBulkTodayResults = async (memberIds) => { + if (!memberIds || memberIds.length === 0) return {}; + + const today = normaliseToMidnight(); + + const results = await prisma.dailyResult.findMany({ + where: { + memberId: { in: memberIds }, + date: today, + }, + }); + + return results.reduce((acc, result) => { + acc[result.memberId] = result; + return acc; + }, {}); +}; + /** * Get today's status for a member * @param {string} memberId - Challenge member ID * @returns {Object|null} Today's daily result or null */ const getTodayStatus = async (memberId) => { - const today = new Date(); - today.setHours(0, 0, 0, 0); + const today = normaliseToMidnight(); return await prisma.dailyResult.findUnique({ where: { @@ -343,5 +438,8 @@ module.exports = { evaluateChallenge, evaluateMember, getMemberDailyResults, + getBulkMemberDailyResults, + getBulkAllMemberResults, + getBulkTodayResults, getTodayStatus, };