diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..9bf925c --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,12 @@ +{ + "servers": { + "mongodbLeetCode": { + "command": "npx", + "args": [ + "-y", + "mcp-mongo-server", + "mongodb+srv://new-admin:OJhhcbw3LFrZwbxC@leetcode.vco1osy.mongodb.net/?retryWrites=true&w=majority&appName=leetCode" + ] + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index 661afbd..d11a76c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ This Discord bot tracks LeetCode activity for specified users and posts updates - [Running Specific Tests](#running-specific-tests) - [License](#license) - [Changelog](#changelog) + - [v2.1.1 (2025-05-04)](#v211-2025-05-04) - [v2.1.0 (2025-05-04)](#v210-2025-05-04) - [v2.0.0 (2025-05-02)](#v200-2025-05-02) - [v1.0.0](#v100) @@ -95,11 +96,27 @@ When the bot joins a new server: - MongoDB Atlas integration for reliable data storage - Per-server announcement channels - Automated welcome message with setup instructions +- Advanced Streak Tracking System: + - Daily streak counting + - Automatic streak maintenance + - Streak preservation across timezone boundaries + - Streak reset on missed days + - Per-guild streak leaderboards +- Submission Tracking and Validation: + - Normalized UTC timestamp handling + - Duplicate submission prevention + - Accurate streak counting with date normalization + - Complete submission history +- User Progress Features: + - Daily challenge completion tracking + - Individual streak statistics + - Weekly and monthly completion rates + - Server-wide leaderboards - Permission-based command system: - Users can add/remove themselves - Admins can manage all users - Channel management requires "Manage Channels" permission -- Optional Discord user mentions when reporting challenge status + - Optional Discord user mentions when reporting challenge status - Flexible cron job management for check schedules - Detailed problem information in status updates: - Problem difficulty @@ -223,6 +240,17 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file ## Changelog +### v2.1.1 (2025-05-04) +- ✨ Enhanced streak tracking system + - Improved date handling with UTC normalization + - Fixed streak counting across timezone boundaries + - Added streak preservation logic + - Enhanced duplicate submission detection +- πŸ”„ Improved submission validation +- πŸ“Š Added per-guild leaderboards +- ⚑️ Optimized database queries +- πŸ› Fixed streak reset issues + ### v2.1.0 (2025-05-04) - ✨ Added submission tracking with MongoDB - πŸŽ‰ Added welcome message when bot joins a server diff --git a/modules/apiUtils.js b/modules/apiUtils.js index 6150090..edcf74d 100644 --- a/modules/apiUtils.js +++ b/modules/apiUtils.js @@ -1,6 +1,7 @@ const axios = require('axios'); const logger = require('./logger'); const DailySubmission = require('./models/DailySubmission'); +const { calculateStreak } = require('./statsUtils'); // Fetch today’s daily challenge slug async function getDailySlug() { @@ -76,7 +77,6 @@ async function enhancedCheck(users, client, channelId) { const topicTags = problem.topicTags ? problem.topicTags.map(tag => tag.name).join(', ') : 'N/A'; const stats = problem.stats ? JSON.parse(problem.stats) : { acRate: 'N/A' }; - // Create problem info field const problemField = { name: 'Problem Info', value: `**${problem.title || 'Unknown Problem'}** (${problem.difficulty || 'N/A'})\n` + @@ -86,7 +86,7 @@ async function enhancedCheck(users, client, channelId) { }; const today = new Date(); - today.setHours(0, 0, 0, 0); + today.setUTCHours(0, 0, 0, 0); // Create individual fields for each user status const userStatusFields = await Promise.all(users.map(async username => { @@ -107,7 +107,7 @@ async function enhancedCheck(users, client, channelId) { // Check if we already have a submission record for today const existingSubmission = await DailySubmission.findOne({ guildId: guild.id, - userId: userId, + userId, leetcodeUsername: username, questionSlug: dailyData, date: { @@ -127,7 +127,9 @@ async function enhancedCheck(users, client, channelId) { questionTitle: problem.title, questionSlug: dailyData, difficulty: problem.difficulty, - submissionTime + submissionTime, + completed: true, + streakCount: await calculateStreak(userId, guild.id) }); } } catch (error) { diff --git a/modules/commandRegistration.js b/modules/commandRegistration.js index 1b9b538..16997af 100644 --- a/modules/commandRegistration.js +++ b/modules/commandRegistration.js @@ -82,6 +82,26 @@ const commands = [ new SlashCommandBuilder() .setName('botinfo') .setDescription('Display information about the bot and its GitHub repository') + .toJSON(), + new SlashCommandBuilder() + .setName('streak') + .setDescription('Check your current streak for completing LeetCode Daily Challenges') + .toJSON(), + new SlashCommandBuilder() + .setName('leaderboard') + .setDescription('View the leaderboard for LeetCode Daily Challenge streaks in this server') + .toJSON(), + new SlashCommandBuilder() + .setName('stats') + .setDescription('View your weekly or monthly completion stats for LeetCode Daily Challenges') + .addStringOption(option => + option.setName('period') + .setDescription('Choose the period: weekly or monthly') + .setRequired(true) + .addChoices( + { name: 'Weekly', value: 'weekly' }, + { name: 'Monthly', value: 'monthly' } + )) .toJSON() ]; diff --git a/modules/interactionHandler.js b/modules/interactionHandler.js index c89d807..1b88a7e 100644 --- a/modules/interactionHandler.js +++ b/modules/interactionHandler.js @@ -2,6 +2,7 @@ const { addUser, removeUser, getGuildUsers, initializeGuildConfig, updateGuildCh const { enhancedCheck } = require('./apiUtils'); const { updateGuildCronJobs } = require('./scheduledTasks'); const logger = require('./logger'); +const { calculateStreak, calculateCompletionRates, generateLeaderboard } = require('./statsUtils'); async function handleInteraction(interaction) { logger.info(`Interaction received: ${interaction.commandName}`); @@ -40,6 +41,15 @@ async function handleInteraction(interaction) { case 'botinfo': await handleBotInfo(interaction); break; + case 'streak': + await handleStreak(interaction); + break; + case 'leaderboard': + await handleLeaderboard(interaction); + break; + case 'stats': + await handleStats(interaction); + break; default: await interaction.reply('Unknown command.'); } @@ -239,4 +249,32 @@ async function handleBotInfo(interaction) { await interaction.reply({ embeds: [botInfoEmbed] }); } +async function handleStreak(interaction) { + await interaction.deferReply(); + const streak = await calculateStreak(interaction.user.id, interaction.guildId); + await interaction.editReply(`Your current streak is **${streak}** days! Keep it up!`); +} + +async function handleLeaderboard(interaction) { + await interaction.deferReply(); + const leaderboard = await generateLeaderboard(interaction.guildId); + if (leaderboard.length === 0) { + await interaction.editReply('No leaderboard data available yet. Encourage your server members to participate!'); + return; + } + + const leaderboardMessage = leaderboard + .map(entry => `**#${entry.rank}** <@${entry.userId}> - **${entry.streak}** days`) + .join('\n'); + + await interaction.editReply(`πŸ† **Leaderboard** πŸ†\n${leaderboardMessage}`); +} + +async function handleStats(interaction) { + await interaction.deferReply(); + const period = interaction.options.getString('period'); + const stats = await calculateCompletionRates(interaction.user.id, interaction.guildId, period); + await interaction.editReply(`You have completed **${stats.total}** challenges in the past ${stats.period}. Great job!`); +} + module.exports = { handleInteraction }; \ No newline at end of file diff --git a/modules/models/DailySubmission.js b/modules/models/DailySubmission.js index 7e72145..38a3993 100644 --- a/modules/models/DailySubmission.js +++ b/modules/models/DailySubmission.js @@ -1,4 +1,4 @@ - const mongoose = require('mongoose'); +const mongoose = require('mongoose'); const dailySubmissionSchema = new mongoose.Schema({ guildId: { @@ -18,7 +18,13 @@ const dailySubmissionSchema = new mongoose.Schema({ date: { type: Date, required: true, - index: true + index: true, + set: function(val) { + // Normalize date to midnight UTC + const date = new Date(val); + date.setUTCHours(0, 0, 0, 0); + return date; + } }, questionTitle: { type: String, @@ -36,10 +42,49 @@ const dailySubmissionSchema = new mongoose.Schema({ submissionTime: { type: Date, required: true + }, + completed: { + type: Boolean, + required: true, + default: false + }, + streakCount: { + type: Number, + required: true, + default: 0 } }); // Compound index for efficient querying of user submissions within a guild dailySubmissionSchema.index({ guildId: 1, userId: 1, date: -1 }); +// Pre-save middleware to handle streak calculation +dailySubmissionSchema.pre('save', async function(next) { + try { + if (this.isNew && this.completed) { + const yesterday = new Date(this.date); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setUTCHours(0, 0, 0, 0); + + // Find yesterday's submission + const prevSubmission = await this.constructor.findOne({ + userId: this.userId, + guildId: this.guildId, + completed: true, + date: yesterday + }).sort({ date: -1 }); + + // If there was a submission yesterday, increment streak + // Otherwise start a new streak at 1 + this.streakCount = prevSubmission ? prevSubmission.streakCount + 1 : 1; + } else if (!this.completed) { + // Reset streak if submission is marked as incomplete + this.streakCount = 0; + } + next(); + } catch (error) { + next(error); + } +}); + module.exports = mongoose.model('DailySubmission', dailySubmissionSchema); \ No newline at end of file diff --git a/modules/scheduledTasks.js b/modules/scheduledTasks.js index b782d0d..d7bba9c 100644 --- a/modules/scheduledTasks.js +++ b/modules/scheduledTasks.js @@ -5,6 +5,7 @@ const axios = require('axios'); const logger = require('./logger'); const Guild = require('./models/Guild'); const DailySubmission = require('./models/DailySubmission'); +const { calculateStreak } = require('./statsUtils'); // Helper function to safely parse submission timestamp function parseSubmissionTime(submission) { @@ -156,7 +157,9 @@ async function scheduleDailyCheck(client, guildId, channelId, schedule) { questionTitle: problem.title, questionSlug: dailySlug, difficulty: problem.difficulty, - submissionTime + submissionTime, + completed: true, // Explicitly set completed to true + streakCount: await calculateStreak(discordId || username, guildId) }); } } else { diff --git a/modules/statsUtils.js b/modules/statsUtils.js new file mode 100644 index 0000000..a8e5fa1 --- /dev/null +++ b/modules/statsUtils.js @@ -0,0 +1,93 @@ +const DailySubmission = require('./models/DailySubmission'); + +/** + * Calculate streaks for a user based on their daily submissions. + * @param {String} userId - The ID of the user. + * @param {String} guildId - The ID of the guild. + * @returns {Promise} - The current streak count. + */ +async function calculateStreak(userId, guildId) { + const latestSubmission = await DailySubmission.findOne({ + userId, + guildId, + completed: true + }).sort({ date: -1 }); + + if (!latestSubmission) { + return 0; + } + + // Check if the latest submission is from today or yesterday + const now = new Date(); + const submissionDate = new Date(latestSubmission.date); + const isToday = submissionDate.toDateString() === now.toDateString(); + const isYesterday = submissionDate.toDateString() === new Date(now.setDate(now.getDate() - 1)).toDateString(); + + // If the latest submission is not from today or yesterday, streak is broken + if (!isToday && !isYesterday) { + return 0; + } + + return latestSubmission.streakCount; +} + +/** + * Calculate weekly or monthly completion rates for a user. + * @param {String} userId - The ID of the user. + * @param {String} guildId - The ID of the guild. + * @param {String} period - 'weekly' or 'monthly'. + * @returns {Promise} - Completion rates. + */ +async function calculateCompletionRates(userId, guildId, period) { + const now = new Date(); + const startDate = new Date( + period === 'weekly' ? now.setDate(now.getDate() - 7) : now.setMonth(now.getMonth() - 1) + ); + + const submissions = await DailySubmission.find({ + userId, + guildId, + date: { $gte: startDate }, + completed: true + }); + + return { + total: submissions.length, + period + }; +} + +/** + * Generate a leaderboard for a guild based on streaks. + * @param {String} guildId - The ID of the guild. + * @returns {Promise} - Leaderboard data. + */ +async function generateLeaderboard(guildId) { + // Get latest submission for each user to check their current streak + const now = new Date(); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + + // Find latest submissions within today or yesterday that have a streak + const users = await DailySubmission.find({ + guildId, + completed: true, + date: { + $gte: new Date(yesterday.setHours(0, 0, 0, 0)) + }, + streakCount: { $gt: 0 } + }).sort({ streakCount: -1, date: -1 }).limit(10); + + // Map to leaderboard format + return users.map((submission, index) => ({ + rank: index + 1, + userId: submission.userId, + streak: submission.streakCount + })); +} + +module.exports = { + calculateStreak, + calculateCompletionRates, + generateLeaderboard +}; \ No newline at end of file