diff --git a/.replit b/.replit index 5b6f647..641ca02 100644 --- a/.replit +++ b/.replit @@ -22,6 +22,10 @@ externalPort = 80 localPort = 8080 externalPort = 8080 +[[ports]] +localPort = 35463 +externalPort = 3000 + [workflows] runButton = "Project" diff --git a/aethex-bot/commands/leaderboard.js b/aethex-bot/commands/leaderboard.js index 9438a6e..694ac31 100644 --- a/aethex-bot/commands/leaderboard.js +++ b/aethex-bot/commands/leaderboard.js @@ -10,10 +10,12 @@ module.exports = { .setDescription("Leaderboard category") .setRequired(false) .addChoices( + { name: "⭐ XP Leaders (All-Time)", value: "xp" }, + { name: "📅 This Week", value: "weekly" }, + { name: "📆 This Month", value: "monthly" }, { name: "🔥 Most Active (Posts)", value: "posts" }, { name: "❤️ Most Liked", value: "likes" }, - { name: "🎨 Top Creators", value: "creators" }, - { name: "⭐ XP Leaders", value: "xp" } + { name: "🎨 Top Creators", value: "creators" } ) ), @@ -25,14 +27,116 @@ module.exports = { try { const category = interaction.options.getString("category") || "xp"; + const guildId = interaction.guildId; let leaderboardData = []; let title = ""; let emoji = ""; let color = 0x7c3aed; + let periodInfo = ""; - if (category === "xp") { - title = "XP Leaderboard"; + if (category === "weekly") { + title = "Weekly XP Leaderboard"; + emoji = "📅"; + color = 0x22c55e; + + const now = new Date(); + const dayOfWeek = now.getDay(); + const diffToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const weekStart = new Date(now); + weekStart.setDate(now.getDate() - diffToMonday); + weekStart.setHours(0, 0, 0, 0); + const weekStartStr = weekStart.toISOString().split('T')[0]; + + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 6); + periodInfo = `${weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${weekEnd.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`; + + // Fetch weekly records using period_type + const { data: periodicData } = await supabase + .from("periodic_xp") + .select("discord_id, weekly_xp, weekly_messages") + .eq("guild_id", guildId) + .eq("period_type", "week") + .eq("period_start", weekStartStr); + + // Aggregate per user (handles multiple records if they exist) + const aggregated = {}; + for (const entry of periodicData || []) { + if (!aggregated[entry.discord_id]) { + aggregated[entry.discord_id] = { xp: 0, messages: 0 }; + } + aggregated[entry.discord_id].xp += entry.weekly_xp || 0; + aggregated[entry.discord_id].messages += entry.weekly_messages || 0; + } + + // Sort and limit after aggregation + const sortedUsers = Object.entries(aggregated) + .sort(([, a], [, b]) => b.xp - a.xp) + .slice(0, 10); + + for (const [discordId, data] of sortedUsers) { + try { + const member = await interaction.guild.members.fetch(discordId).catch(() => null); + const displayName = member?.displayName || member?.user?.username || "Unknown User"; + leaderboardData.push({ + name: displayName, + value: `${data.xp.toLocaleString()} XP • ${data.messages} msgs`, + xp: data.xp + }); + } catch (e) { + continue; + } + } + } else if (category === "monthly") { + title = "Monthly XP Leaderboard"; + emoji = "📆"; + color = 0x3b82f6; + + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const monthStartStr = monthStart.toISOString().split('T')[0]; + + periodInfo = monthStart.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); + + // Fetch monthly records using period_type + const { data: periodicData } = await supabase + .from("periodic_xp") + .select("discord_id, monthly_xp, monthly_messages") + .eq("guild_id", guildId) + .eq("period_type", "month") + .eq("period_start", monthStartStr); + + // Aggregate all entries per user first + const aggregated = {}; + for (const entry of periodicData || []) { + if (!aggregated[entry.discord_id]) { + aggregated[entry.discord_id] = { xp: 0, messages: 0 }; + } + aggregated[entry.discord_id].xp += entry.monthly_xp || 0; + aggregated[entry.discord_id].messages += entry.monthly_messages || 0; + } + + // Sort and limit AFTER aggregation + const sortedUsers = Object.entries(aggregated) + .sort(([, a], [, b]) => b.xp - a.xp) + .slice(0, 10); + + for (const [discordId, data] of sortedUsers) { + try { + const member = await interaction.guild.members.fetch(discordId).catch(() => null); + const displayName = member?.displayName || member?.user?.username || "Unknown User"; + leaderboardData.push({ + name: displayName, + value: `${data.xp.toLocaleString()} XP • ${data.messages} msgs`, + xp: data.xp + }); + } catch (e) { + continue; + } + } + } else if (category === "xp") { + title = "XP Leaderboard (All-Time)"; emoji = "⭐"; color = 0xfbbf24; @@ -176,14 +280,30 @@ module.exports = { }) .setTimestamp(); + if (periodInfo) { + embed.addFields({ + name: '📊 Period', + value: periodInfo, + inline: true + }); + } + if (leaderboardData.length > 0) { embed.addFields({ - name: '📊 Stats', - value: `Showing top ${leaderboardData.length} contributors`, + name: '👥 Showing', + value: `Top ${leaderboardData.length} contributors`, inline: true }); } + if (category === "weekly" || category === "monthly") { + embed.addFields({ + name: '💡 Tip', + value: 'Leaderboards reset automatically at the start of each period!', + inline: false + }); + } + await interaction.editReply({ embeds: [embed] }); } catch (error) { console.error("Leaderboard command error:", error); diff --git a/aethex-bot/docs/MANUAL.md b/aethex-bot/docs/MANUAL.md index 11934f4..4e364b9 100644 --- a/aethex-bot/docs/MANUAL.md +++ b/aethex-bot/docs/MANUAL.md @@ -193,14 +193,29 @@ This means if you earn a special role in one realm, it can automatically apply t See who's leading the community: ``` -/leaderboard +/leaderboard [category] ``` +**Category Options:** +| Option | Description | +|--------|-------------| +| `xp` | All-time XP leaders (default) | +| `weekly` | This week's top earners | +| `monthly` | This month's top earners | +| `posts` | Most active posters | +| `likes` | Most liked users | +| `creators` | Top project creators | + +**Weekly/Monthly Leaderboards:** +- Track XP earned within the current period +- Reset automatically at the start of each week (Monday) or month +- Show messages count alongside XP earned +- Great for recurring engagement competitions! + Features: - Medal rankings (🥇 🥈 🥉) - XP totals and levels -- Progress bars -- Weekly/monthly views +- Automatic period resets ### Community Posts diff --git a/aethex-bot/listeners/reactionXp.js b/aethex-bot/listeners/reactionXp.js index 8b96fee..bb4edd0 100644 --- a/aethex-bot/listeners/reactionXp.js +++ b/aethex-bot/listeners/reactionXp.js @@ -1,4 +1,4 @@ -const { checkMilestoneRoles, calculateLevel, updateUserStats, getUserStats } = require('./xpTracker'); +const { checkMilestoneRoles, calculateLevel, updateUserStats, getUserStats, updatePeriodicXp } = require('./xpTracker'); const { checkAchievements } = require('../commands/achievements'); const reactionCooldowns = new Map(); @@ -129,6 +129,9 @@ async function grantXp(supabase, discordUserId, xpAmount, client, member, config client.trackXP(finalXp); } + // Track periodic XP for weekly/monthly leaderboards (false = not a message) + await updatePeriodicXp(supabase, link.user_id, guildId, discordUserId, finalXp, false); + // Track reaction stats const statIncrement = reactionType === 'giver' ? { reactionsGiven: 1 } diff --git a/aethex-bot/listeners/voiceXp.js b/aethex-bot/listeners/voiceXp.js index 3a57670..0178a5e 100644 --- a/aethex-bot/listeners/voiceXp.js +++ b/aethex-bot/listeners/voiceXp.js @@ -1,4 +1,4 @@ -const { checkMilestoneRoles, calculateLevel, updateUserStats, getUserStats } = require('./xpTracker'); +const { checkMilestoneRoles, calculateLevel, updateUserStats, getUserStats, updatePeriodicXp } = require('./xpTracker'); const { checkAchievements } = require('../commands/achievements'); const voiceSessions = new Map(); @@ -198,6 +198,9 @@ async function grantVoiceXp(supabase, client, guildId, userId, config, member, m client.trackXP(xpGain); } + // Track periodic XP for weekly/monthly leaderboards (false = not a message) + await updatePeriodicXp(supabase, link.user_id, guildId, userId, xpGain, false); + // Track voice minutes for achievements await updateUserStats(supabase, link.user_id, guildId, { voiceMinutes: minutes }); diff --git a/aethex-bot/listeners/xpTracker.js b/aethex-bot/listeners/xpTracker.js index c5378f1..3561fb4 100644 --- a/aethex-bot/listeners/xpTracker.js +++ b/aethex-bot/listeners/xpTracker.js @@ -104,6 +104,9 @@ module.exports = { client.trackXP(xpGain); } + // Track periodic XP for weekly/monthly leaderboards (true = is a message) + await updatePeriodicXp(supabase, link.user_id, guildId, discordUserId, xpGain, true); + // Update user stats for achievements await updateUserStats(supabase, link.user_id, guildId, { messages: 1 }); @@ -373,8 +376,89 @@ async function getUserStats(supabase, userId, guildId) { } } +async function updatePeriodicXp(supabase, userId, guildId, discordId, xpGain, isMessage = false) { + try { + const now = new Date(); + + // Calculate week start (Monday) + const dayOfWeek = now.getDay(); + const diffToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const weekStart = new Date(now); + weekStart.setDate(now.getDate() - diffToMonday); + weekStart.setHours(0, 0, 0, 0); + const weekStartStr = weekStart.toISOString().split('T')[0]; + + // Calculate month start + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const monthStartStr = monthStart.toISOString().split('T')[0]; + + // Update weekly record (period_type = 'week', period_start = week_start) + await upsertPeriodXp(supabase, userId, guildId, discordId, 'week', weekStartStr, xpGain, isMessage); + + // Update monthly record (period_type = 'month', period_start = month_start) + await upsertPeriodXp(supabase, userId, guildId, discordId, 'month', monthStartStr, xpGain, isMessage); + + } catch (e) { + // Table may not exist - silently ignore + } +} + +async function upsertPeriodXp(supabase, userId, guildId, discordId, periodType, periodStart, xpGain, isMessage) { + try { + // Find record by (user, guild, period_type, period_start) + const { data: existing } = await supabase + .from('periodic_xp') + .select('*') + .eq('user_id', userId) + .eq('guild_id', guildId) + .eq('period_type', periodType) + .eq('period_start', periodStart) + .maybeSingle(); + + if (existing) { + const xpField = periodType === 'week' ? 'weekly_xp' : 'monthly_xp'; + const msgField = periodType === 'week' ? 'weekly_messages' : 'monthly_messages'; + + const updates = { + [xpField]: (existing[xpField] || 0) + xpGain, + updated_at: new Date().toISOString() + }; + + if (isMessage) { + updates[msgField] = (existing[msgField] || 0) + 1; + } + + await supabase + .from('periodic_xp') + .update(updates) + .eq('id', existing.id); + } else { + const insertData = { + user_id: userId, + guild_id: guildId, + discord_id: discordId, + period_type: periodType, + period_start: periodStart, + week_start: periodType === 'week' ? periodStart : null, + month_start: periodType === 'month' ? periodStart : null, + weekly_xp: periodType === 'week' ? xpGain : 0, + monthly_xp: periodType === 'month' ? xpGain : 0, + weekly_messages: (periodType === 'week' && isMessage) ? 1 : 0, + monthly_messages: (periodType === 'month' && isMessage) ? 1 : 0 + }; + + await supabase + .from('periodic_xp') + .insert(insertData); + } + } catch (e) { + // Silently ignore + } +} + // Export functions for use in other commands module.exports.calculateLevel = calculateLevel; module.exports.checkMilestoneRoles = checkMilestoneRoles; module.exports.updateUserStats = updateUserStats; module.exports.getUserStats = getUserStats; +module.exports.updatePeriodicXp = updatePeriodicXp;