Add separate weekly and monthly XP tracking for leaderboards
Refactor XP tracking to differentiate between weekly and monthly periods, updating `periodic_xp` table and leaderboard queries. Replit-Commit-Author: Agent Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 54606488-64d4-4a79-b52c-e95591c2e4fc Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/aed2e46d-25bb-4b73-81a1-bb9e8437c261/zRLxuQq Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
4a39363dfd
commit
e5f6956392
6 changed files with 240 additions and 11 deletions
4
.replit
4
.replit
|
|
@ -22,6 +22,10 @@ externalPort = 80
|
||||||
localPort = 8080
|
localPort = 8080
|
||||||
externalPort = 8080
|
externalPort = 8080
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 35463
|
||||||
|
externalPort = 3000
|
||||||
|
|
||||||
[workflows]
|
[workflows]
|
||||||
runButton = "Project"
|
runButton = "Project"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ module.exports = {
|
||||||
.setDescription("Leaderboard category")
|
.setDescription("Leaderboard category")
|
||||||
.setRequired(false)
|
.setRequired(false)
|
||||||
.addChoices(
|
.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 Active (Posts)", value: "posts" },
|
||||||
{ name: "❤️ Most Liked", value: "likes" },
|
{ name: "❤️ Most Liked", value: "likes" },
|
||||||
{ name: "🎨 Top Creators", value: "creators" },
|
{ name: "🎨 Top Creators", value: "creators" }
|
||||||
{ name: "⭐ XP Leaders", value: "xp" }
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
@ -25,14 +27,116 @@ module.exports = {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const category = interaction.options.getString("category") || "xp";
|
const category = interaction.options.getString("category") || "xp";
|
||||||
|
const guildId = interaction.guildId;
|
||||||
|
|
||||||
let leaderboardData = [];
|
let leaderboardData = [];
|
||||||
let title = "";
|
let title = "";
|
||||||
let emoji = "";
|
let emoji = "";
|
||||||
let color = 0x7c3aed;
|
let color = 0x7c3aed;
|
||||||
|
let periodInfo = "";
|
||||||
|
|
||||||
if (category === "xp") {
|
if (category === "weekly") {
|
||||||
title = "XP Leaderboard";
|
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 = "⭐";
|
emoji = "⭐";
|
||||||
color = 0xfbbf24;
|
color = 0xfbbf24;
|
||||||
|
|
||||||
|
|
@ -176,14 +280,30 @@ module.exports = {
|
||||||
})
|
})
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
|
if (periodInfo) {
|
||||||
|
embed.addFields({
|
||||||
|
name: '📊 Period',
|
||||||
|
value: periodInfo,
|
||||||
|
inline: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (leaderboardData.length > 0) {
|
if (leaderboardData.length > 0) {
|
||||||
embed.addFields({
|
embed.addFields({
|
||||||
name: '📊 Stats',
|
name: '👥 Showing',
|
||||||
value: `Showing top ${leaderboardData.length} contributors`,
|
value: `Top ${leaderboardData.length} contributors`,
|
||||||
inline: true
|
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] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Leaderboard command error:", error);
|
console.error("Leaderboard command error:", error);
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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:
|
Features:
|
||||||
- Medal rankings (🥇 🥈 🥉)
|
- Medal rankings (🥇 🥈 🥉)
|
||||||
- XP totals and levels
|
- XP totals and levels
|
||||||
- Progress bars
|
- Automatic period resets
|
||||||
- Weekly/monthly views
|
|
||||||
|
|
||||||
### Community Posts
|
### Community Posts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { checkAchievements } = require('../commands/achievements');
|
||||||
|
|
||||||
const reactionCooldowns = new Map();
|
const reactionCooldowns = new Map();
|
||||||
|
|
@ -129,6 +129,9 @@ async function grantXp(supabase, discordUserId, xpAmount, client, member, config
|
||||||
client.trackXP(finalXp);
|
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
|
// Track reaction stats
|
||||||
const statIncrement = reactionType === 'giver'
|
const statIncrement = reactionType === 'giver'
|
||||||
? { reactionsGiven: 1 }
|
? { reactionsGiven: 1 }
|
||||||
|
|
|
||||||
|
|
@ -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 { checkAchievements } = require('../commands/achievements');
|
||||||
|
|
||||||
const voiceSessions = new Map();
|
const voiceSessions = new Map();
|
||||||
|
|
@ -198,6 +198,9 @@ async function grantVoiceXp(supabase, client, guildId, userId, config, member, m
|
||||||
client.trackXP(xpGain);
|
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
|
// Track voice minutes for achievements
|
||||||
await updateUserStats(supabase, link.user_id, guildId, { voiceMinutes: minutes });
|
await updateUserStats(supabase, link.user_id, guildId, { voiceMinutes: minutes });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,9 @@ module.exports = {
|
||||||
client.trackXP(xpGain);
|
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
|
// Update user stats for achievements
|
||||||
await updateUserStats(supabase, link.user_id, guildId, { messages: 1 });
|
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
|
// Export functions for use in other commands
|
||||||
module.exports.calculateLevel = calculateLevel;
|
module.exports.calculateLevel = calculateLevel;
|
||||||
module.exports.checkMilestoneRoles = checkMilestoneRoles;
|
module.exports.checkMilestoneRoles = checkMilestoneRoles;
|
||||||
module.exports.updateUserStats = updateUserStats;
|
module.exports.updateUserStats = updateUserStats;
|
||||||
module.exports.getUserStats = getUserStats;
|
module.exports.getUserStats = getUserStats;
|
||||||
|
module.exports.updatePeriodicXp = updatePeriodicXp;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue