Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 90 additions & 94 deletions src/controllers/dashboard.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -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,
Expand Down
102 changes: 100 additions & 2 deletions src/services/evaluation.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -343,5 +438,8 @@ module.exports = {
evaluateChallenge,
evaluateMember,
getMemberDailyResults,
getBulkMemberDailyResults,
getBulkAllMemberResults,
getBulkTodayResults,
getTodayStatus,
};