feat: implement comprehensive gamification, analytics, and leaderboard system

This commit adds a complete gamification system with analytics dashboards,
leaderboards, and enhanced badge tracking functionality.

Backend Features:
- Analytics API with overview, user stats, activity trends, top contributors,
  and street statistics endpoints
- Leaderboard API supporting global, weekly, monthly, and friends views
- Profile API for viewing and managing user profiles
- Enhanced gamification service with badge progress tracking and user stats
- Comprehensive test coverage for analytics and leaderboard endpoints
- Profile validation middleware for secure profile updates

Frontend Features:
- Analytics dashboard with multiple tabs (Overview, Activity, Personal Stats)
- Interactive charts for activity trends and street statistics
- Leaderboard component with pagination and timeframe filtering
- Badge collection display with progress tracking
- Personal stats component showing user achievements
- Contributors list for top performing users
- Profile management components (View/Edit)
- Toast notifications integrated throughout
- Comprehensive test coverage for Leaderboard component

Enhancements:
- User model enhanced with stats tracking and badge management
- Fixed express.Router() capitalization bug in users route
- Badge service improvements for better criteria matching
- Removed unused imports in Profile component

This feature enables users to track their contributions, view community
analytics, compete on leaderboards, and earn badges for achievements.

🤖 Generated with OpenCode

Co-Authored-By: AI Assistant <noreply@opencode.ai>
This commit is contained in:
William Valentin
2025-11-03 13:53:48 -08:00
parent ae77e30ffb
commit 3e4c730860
34 changed files with 5533 additions and 190 deletions

View File

@@ -113,14 +113,14 @@ async function checkAndAwardBadges(userId, userPoints = null) {
// Check each badge criteria
for (const badge of allBadges) {
// Skip if user already has this badge
if (userBadges.some(ub => ub.badgeId === badge._id)) {
if (userBadges.some(ub => ub.badge?._id === badge._id || ub.badge === badge._id)) {
continue;
}
let qualifies = false;
// Check different badge criteria
switch (badge.criteria.type) {
switch (badge.criteria?.type) {
case 'points_earned':
qualifies = userPoints >= badge.criteria.threshold;
break;
@@ -128,13 +128,13 @@ async function checkAndAwardBadges(userId, userPoints = null) {
qualifies = userStats.streetAdoptions >= badge.criteria.threshold;
break;
case 'task_completions':
qualifies = userStats.taskCompletions >= badge.criteria.threshold;
qualifies = userStats.tasksCompleted >= badge.criteria.threshold;
break;
case 'post_creations':
qualifies = userStats.postCreations >= badge.criteria.threshold;
qualifies = userStats.postsCreated >= badge.criteria.threshold;
break;
case 'event_participations':
qualifies = userStats.eventParticipations >= badge.criteria.threshold;
qualifies = userStats.eventsParticipated >= badge.criteria.threshold;
break;
case 'consecutive_days':
qualifies = userStats.consecutiveDays >= badge.criteria.threshold;
@@ -168,9 +168,9 @@ async function awardBadge(userId, badgeId) {
// Create user badge record
const userBadge = await UserBadge.create({
userId: userId,
badgeId: badgeId,
awardedAt: new Date().toISOString(),
user: userId,
badge: badgeId,
earnedAt: new Date().toISOString(),
});
// Award points for earning badge (if it's a rare or higher badge)
@@ -205,16 +205,19 @@ async function awardBadge(userId, badgeId) {
*/
async function getUserStats(userId) {
try {
// This would typically involve querying various collections
// For now, return basic stats - this should be enhanced
const user = await User.findById(userId);
if (!user) {
throw new Error("User not found");
}
return {
streetAdoptions: 0, // Would query Street collection
taskCompletions: 0, // Would query Task collection
postCreations: 0, // Would query Post collection
eventParticipations: 0, // Would query Event participation
consecutiveDays: 0, // Would calculate from login history
points: user.points || 0,
streetsAdopted: user.stats?.streetsAdopted || 0,
tasksCompleted: user.stats?.tasksCompleted || 0,
postsCreated: user.stats?.postsCreated || 0,
eventsParticipated: user.stats?.eventsParticipated || 0,
badgesEarned: user.stats?.badgesEarned || 0,
consecutiveDays: user.stats?.consecutiveDays || 0,
};
} catch (error) {
console.error("Error getting user stats:", error);
@@ -222,6 +225,71 @@ async function getUserStats(userId) {
}
}
/**
* Get user's badge progress for all badges (earned and unearned)
*/
async function getUserBadgeProgress(userId) {
try {
const allBadges = await Badge.findAll();
const userStats = await getUserStats(userId);
const userEarnedBadges = await UserBadge.findByUser(userId);
const badgeProgress = allBadges.map(badge => {
const earnedBadge = userEarnedBadges.find(ub => ub.badge?._id === badge._id || ub.badge === badge._id);
const isEarned = !!earnedBadge;
let progress = 0;
let threshold = badge.criteria?.threshold || 0;
if (isEarned) {
progress = threshold; // If earned, progress is full
} else if (badge.criteria?.type) {
switch (badge.criteria.type) {
case 'points_earned':
progress = userStats.points || 0;
break;
case 'street_adoptions':
progress = userStats.streetsAdopted;
break;
case 'task_completions':
progress = userStats.tasksCompleted;
break;
case 'post_creations':
progress = userStats.postsCreated;
break;
case 'event_participations':
progress = userStats.eventsParticipated;
break;
case 'consecutive_days':
progress = userStats.consecutiveDays;
break;
case 'special':
progress = 0; // Special badges have no progress bar
threshold = 1;
break;
default:
progress = 0;
}
}
// Ensure progress doesn't exceed threshold
progress = Math.min(progress, threshold);
return {
...badge,
isEarned,
progress,
threshold,
earnedAt: isEarned ? earnedBadge.earnedAt : null,
};
});
return badgeProgress;
} catch (error) {
console.error("Error getting user badge progress:", error);
throw error;
}
}
/**
* Get user's badges
*/
@@ -231,11 +299,13 @@ async function getUserBadges(userId) {
const badges = [];
for (const userBadge of userBadges) {
const badge = await Badge.findById(userBadge.badgeId);
const badgeData = userBadge.badge;
// If badge is already populated (object), use it; otherwise fetch it
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
if (badge) {
badges.push({
...badge,
awardedAt: userBadge.awardedAt,
earnedAt: userBadge.earnedAt,
});
}
}
@@ -301,6 +371,455 @@ async function getLeaderboard(limit = 10) {
}
}
/**
* Get global leaderboard (all time)
* @param {number} limit - Number of users to return
* @param {number} offset - Offset for pagination
* @returns {Promise<Array>} Leaderboard data
*/
async function getGlobalLeaderboard(limit = 100, offset = 0) {
try {
const couchdbService = require("./couchdbService");
const Street = require("../models/Street");
const Task = require("../models/Task");
// Get all users sorted by points
const result = await couchdbService.find({
selector: {
type: "user",
points: { $gt: 0 }
},
sort: [{ points: "desc" }],
limit: limit,
skip: offset
});
// Enrich with stats and badges
const leaderboard = await Promise.all(result.map(async (user, index) => {
// Get user badges
const userBadges = await UserBadge.findByUser(user._id);
const badges = await Promise.all(userBadges.map(async (ub) => {
const badgeData = ub.badge;
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
return badge ? {
_id: badge._id,
name: badge.name,
icon: badge.icon,
rarity: badge.rarity
} : null;
}));
return {
rank: offset + index + 1,
userId: user._id,
username: user.name,
avatar: user.profilePicture || null,
points: user.points || 0,
streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0,
tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0,
badges: badges.filter(b => b !== null)
};
}));
return leaderboard;
} catch (error) {
console.error("Error getting global leaderboard:", error);
throw error;
}
}
/**
* Get weekly leaderboard
* @param {number} limit - Number of users to return
* @param {number} offset - Offset for pagination
* @returns {Promise<Array>} Leaderboard data
*/
async function getWeeklyLeaderboard(limit = 100, offset = 0) {
try {
const couchdbService = require("./couchdbService");
// Calculate start of week (Monday 00:00:00)
const now = new Date();
const dayOfWeek = now.getDay();
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - daysToMonday);
startOfWeek.setHours(0, 0, 0, 0);
// Get all point transactions since start of week
const transactions = await couchdbService.find({
selector: {
type: "point_transaction",
createdAt: { $gte: startOfWeek.toISOString() }
}
});
// Aggregate points by user
const userPointsMap = {};
transactions.forEach(transaction => {
if (!userPointsMap[transaction.user]) {
userPointsMap[transaction.user] = 0;
}
userPointsMap[transaction.user] += transaction.amount;
});
// Convert to array and sort
const userPoints = Object.entries(userPointsMap)
.map(([userId, points]) => ({ userId, points }))
.filter(entry => entry.points > 0)
.sort((a, b) => b.points - a.points)
.slice(offset, offset + limit);
// Enrich with user data
const leaderboard = await Promise.all(userPoints.map(async (entry, index) => {
const user = await User.findById(entry.userId);
if (!user) return null;
// Get user badges
const userBadges = await UserBadge.findByUser(userId);
const badges = await Promise.all(userBadges.map(async (ub) => {
const badgeData = ub.badge;
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
return badge ? {
_id: badge._id,
name: badge.name,
icon: badge.icon,
rarity: badge.rarity
} : null;
}));
return {
rank: offset + index + 1,
userId: user._id,
username: user.name,
avatar: user.profilePicture || null,
points: entry.points,
streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0,
tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0,
badges: badges.filter(b => b !== null)
};
}));
return leaderboard.filter(entry => entry !== null);
} catch (error) {
console.error("Error getting weekly leaderboard:", error);
throw error;
}
}
/**
* Get monthly leaderboard
* @param {number} limit - Number of users to return
* @param {number} offset - Offset for pagination
* @returns {Promise<Array>} Leaderboard data
*/
async function getMonthlyLeaderboard(limit = 100, offset = 0) {
try {
const couchdbService = require("./couchdbService");
// Calculate start of month
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
// Get all point transactions since start of month
const transactions = await couchdbService.find({
selector: {
type: "point_transaction",
createdAt: { $gte: startOfMonth.toISOString() }
}
});
// Aggregate points by user
const userPointsMap = {};
transactions.forEach(transaction => {
if (!userPointsMap[transaction.user]) {
userPointsMap[transaction.user] = 0;
}
userPointsMap[transaction.user] += transaction.amount;
});
// Convert to array and sort
const userPoints = Object.entries(userPointsMap)
.map(([userId, points]) => ({ userId, points }))
.filter(entry => entry.points > 0)
.sort((a, b) => b.points - a.points)
.slice(offset, offset + limit);
// Enrich with user data
const leaderboard = await Promise.all(userPoints.map(async (entry, index) => {
const user = await User.findById(entry.userId);
if (!user) return null;
// Get user badges
const userBadges = await UserBadge.findByUser(user._id);
const badges = await Promise.all(userBadges.slice(0, 5).map(async (ub) => {
const badgeData = ub.badge;
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
return badge ? {
_id: badge._id,
name: badge.name,
icon: badge.icon,
rarity: badge.rarity
} : null;
}));
return {
rank: offset + index + 1,
userId: user._id,
username: user.name,
avatar: user.profilePicture || null,
points: entry.points,
streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0,
tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0,
badges: badges.filter(b => b !== null)
};
}));
return leaderboard.filter(entry => entry !== null);
} catch (error) {
console.error("Error getting monthly leaderboard:", error);
throw error;
}
}
/**
* Get friends leaderboard
* @param {string} userId - User ID
* @param {number} limit - Number of users to return
* @param {number} offset - Offset for pagination
* @returns {Promise<Array>} Leaderboard data
*/
async function getFriendsLeaderboard(userId, limit = 100, offset = 0) {
try {
const user = await User.findById(userId);
if (!user) {
throw new Error("User not found");
}
// For now, return empty array as friends system isn't implemented
// In future, would get user's friends list and filter leaderboard
const friendIds = user.friends || [];
if (friendIds.length === 0) {
// Include self if no friends
friendIds.push(userId);
}
const couchdbService = require("./couchdbService");
// Get friends' data
const friends = await couchdbService.find({
selector: {
type: "user",
_id: { $in: friendIds }
}
});
// Sort by points
const sortedFriends = friends
.sort((a, b) => (b.points || 0) - (a.points || 0))
.slice(offset, offset + limit);
// Enrich with badges
const leaderboard = await Promise.all(sortedFriends.map(async (friend, index) => {
const userBadges = await UserBadge.findByUser(friend._id);
const badges = await Promise.all(userBadges.slice(0, 5).map(async (ub) => {
const badgeData = ub.badge;
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
return badge ? {
_id: badge._id,
name: badge.name,
icon: badge.icon,
rarity: badge.rarity
} : null;
}));
return {
rank: offset + index + 1,
userId: friend._id,
username: friend.name,
avatar: friend.profilePicture || null,
points: friend.points || 0,
streetsAdopted: friend.stats?.streetsAdopted || friend.adoptedStreets?.length || 0,
tasksCompleted: friend.stats?.tasksCompleted || friend.completedTasks?.length || 0,
badges: badges.filter(b => b !== null),
isFriend: true
};
}));
return leaderboard;
} catch (error) {
console.error("Error getting friends leaderboard:", error);
throw error;
}
}
/**
* Get user's leaderboard position
* @param {string} userId - User ID
* @param {string} timeframe - 'all', 'week', or 'month'
* @returns {Promise<Object>} User's position data
*/
async function getUserLeaderboardPosition(userId, timeframe = "all") {
try {
const user = await User.findById(userId);
if (!user) {
return null;
}
let rank = 0;
let totalUsers = 0;
let userPoints = 0;
const couchdbService = require("./couchdbService");
if (timeframe === "all") {
// Get all users with points
const allUsers = await couchdbService.find({
selector: {
type: "user",
points: { $gt: 0 }
},
sort: [{ points: "desc" }]
});
totalUsers = allUsers.length;
userPoints = user.points || 0;
// Find user's rank
rank = allUsers.findIndex(u => u._id === userId) + 1;
} else if (timeframe === "week" || timeframe === "month") {
// Calculate start date
const now = new Date();
let startDate;
if (timeframe === "week") {
const dayOfWeek = now.getDay();
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
startDate = new Date(now);
startDate.setDate(now.getDate() - daysToMonday);
startDate.setHours(0, 0, 0, 0);
} else {
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
}
// Get all transactions for period
const transactions = await couchdbService.find({
selector: {
type: "point_transaction",
createdAt: { $gte: startDate.toISOString() }
}
});
// Aggregate points by user
const userPointsMap = {};
transactions.forEach(transaction => {
if (!userPointsMap[transaction.user]) {
userPointsMap[transaction.user] = 0;
}
userPointsMap[transaction.user] += transaction.amount;
});
// Sort users by points
const sortedUsers = Object.entries(userPointsMap)
.filter(([_, points]) => points > 0)
.sort((a, b) => b[1] - a[1]);
totalUsers = sortedUsers.length;
userPoints = userPointsMap[userId] || 0;
rank = sortedUsers.findIndex(([id, _]) => id === userId) + 1;
}
// Get user badges
const userBadges = await UserBadge.findByUser(user._id);
const badges = await Promise.all(userBadges.slice(0, 5).map(async (ub) => {
const badgeData = ub.badge;
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
return badge ? {
_id: badge._id,
name: badge.name,
icon: badge.icon,
rarity: badge.rarity
} : null;
}));
return {
rank: rank || null,
totalUsers,
userId: user._id,
username: user.name,
avatar: user.profilePicture || null,
points: userPoints,
streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0,
tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0,
badges: badges.filter(b => b !== null),
percentile: totalUsers > 0 ? Math.round((1 - (rank - 1) / totalUsers) * 100) : 0
};
} catch (error) {
console.error("Error getting user leaderboard position:", error);
throw error;
}
}
/**
* Get leaderboard statistics
* @returns {Promise<Object>} Statistics data
*/
async function getLeaderboardStats() {
try {
const couchdbService = require("./couchdbService");
// Get all users with points
const allUsers = await couchdbService.find({
selector: {
type: "user",
points: { $gt: 0 }
}
});
// Calculate statistics
const totalUsers = allUsers.length;
const totalPoints = allUsers.reduce((sum, user) => sum + (user.points || 0), 0);
const avgPoints = totalUsers > 0 ? Math.round(totalPoints / totalUsers) : 0;
const maxPoints = allUsers.length > 0 ? Math.max(...allUsers.map(u => u.points || 0)) : 0;
const minPoints = allUsers.length > 0 ? Math.min(...allUsers.map(u => u.points || 0)) : 0;
// Get weekly stats
const now = new Date();
const dayOfWeek = now.getDay();
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - daysToMonday);
startOfWeek.setHours(0, 0, 0, 0);
const weeklyTransactions = await couchdbService.find({
selector: {
type: "point_transaction",
createdAt: { $gte: startOfWeek.toISOString() }
}
});
const weeklyPoints = weeklyTransactions.reduce((sum, t) => sum + (t.amount || 0), 0);
const activeUsersThisWeek = new Set(weeklyTransactions.map(t => t.user)).size;
return {
totalUsers,
totalPoints,
avgPoints,
maxPoints,
minPoints,
weeklyStats: {
totalPoints: weeklyPoints,
activeUsers: activeUsersThisWeek,
transactions: weeklyTransactions.length
}
};
} catch (error) {
console.error("Error getting leaderboard statistics:", error);
throw error;
}
}
module.exports = {
awardPoints,
getUserPoints,
@@ -308,7 +827,15 @@ module.exports = {
checkAndAwardBadges,
awardBadge,
getUserBadges,
getUserBadgeProgress,
getUserStats,
redeemPoints,
getLeaderboard,
getGlobalLeaderboard,
getWeeklyLeaderboard,
getMonthlyLeaderboard,
getFriendsLeaderboard,
getUserLeaderboardPosition,
getLeaderboardStats,
POINT_VALUES,
};