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>
841 lines
24 KiB
JavaScript
841 lines
24 KiB
JavaScript
const User = require("../models/User");
|
|
const PointTransaction = require("../models/PointTransaction");
|
|
const Badge = require("../models/Badge");
|
|
const UserBadge = require("../models/UserBadge");
|
|
|
|
/**
|
|
* Point rewards for different actions
|
|
*/
|
|
const POINT_VALUES = {
|
|
STREET_ADOPTION: 100,
|
|
TASK_COMPLETION: 50,
|
|
POST_CREATION: 10,
|
|
EVENT_PARTICIPATION: 75,
|
|
};
|
|
|
|
/**
|
|
* Awards points to a user with transaction tracking
|
|
*
|
|
* @param {string} userId - User ID
|
|
* @param {number} amount - Points to award (can be negative for deductions)
|
|
* @param {string} type - Transaction type (street_adoption, task_completion, etc.)
|
|
* @param {string} description - Human-readable description
|
|
* @param {Object} relatedEntity - Related entity {entityType, entityId}
|
|
* @returns {Promise<Object>} - Updated user and transaction
|
|
*/
|
|
async function awardPoints(
|
|
userId,
|
|
amount,
|
|
type,
|
|
description,
|
|
relatedEntity = {}
|
|
) {
|
|
try {
|
|
// Get current user
|
|
const user = await User.findById(userId);
|
|
if (!user) {
|
|
throw new Error("User not found");
|
|
}
|
|
|
|
// Calculate new balance
|
|
const currentBalance = user.points || 0;
|
|
const newBalance = currentBalance + amount;
|
|
|
|
// Update user points
|
|
const updatedUser = await User.update(userId, { points: newBalance });
|
|
|
|
// Create transaction record
|
|
const transaction = await PointTransaction.create({
|
|
user: userId,
|
|
amount: amount,
|
|
transactionType: type,
|
|
description: description,
|
|
relatedEntity: relatedEntity,
|
|
balanceAfter: newBalance,
|
|
});
|
|
|
|
// Check for new badges
|
|
await checkAndAwardBadges(userId, newBalance);
|
|
|
|
return {
|
|
user: updatedUser,
|
|
transaction: transaction,
|
|
newBalance: newBalance,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error awarding points:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get user's current point balance
|
|
*/
|
|
async function getUserPoints(userId) {
|
|
try {
|
|
const user = await User.findById(userId);
|
|
return user ? user.points || 0 : 0;
|
|
} catch (error) {
|
|
console.error("Error getting user points:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get user's transaction history
|
|
*/
|
|
async function getUserTransactionHistory(userId, limit = 50, skip = 0) {
|
|
try {
|
|
return await PointTransaction.findByUser(userId, limit, skip);
|
|
} catch (error) {
|
|
console.error("Error getting transaction history:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user qualifies for new badges and award them
|
|
*/
|
|
async function checkAndAwardBadges(userId, userPoints = null) {
|
|
try {
|
|
// Get user's current points if not provided
|
|
if (userPoints === null) {
|
|
userPoints = await getUserPoints(userId);
|
|
}
|
|
|
|
// Get user's stats for badge checking
|
|
const userStats = await getUserStats(userId);
|
|
const userBadges = await UserBadge.findByUser(userId);
|
|
|
|
// Get all available badges
|
|
const allBadges = await Badge.findAll();
|
|
|
|
// Check each badge criteria
|
|
for (const badge of allBadges) {
|
|
// Skip if user already has this badge
|
|
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) {
|
|
case 'points_earned':
|
|
qualifies = userPoints >= badge.criteria.threshold;
|
|
break;
|
|
case 'street_adoptions':
|
|
qualifies = userStats.streetAdoptions >= badge.criteria.threshold;
|
|
break;
|
|
case 'task_completions':
|
|
qualifies = userStats.tasksCompleted >= badge.criteria.threshold;
|
|
break;
|
|
case 'post_creations':
|
|
qualifies = userStats.postsCreated >= badge.criteria.threshold;
|
|
break;
|
|
case 'event_participations':
|
|
qualifies = userStats.eventsParticipated >= badge.criteria.threshold;
|
|
break;
|
|
case 'consecutive_days':
|
|
qualifies = userStats.consecutiveDays >= badge.criteria.threshold;
|
|
break;
|
|
case 'special':
|
|
// Special badges are awarded manually
|
|
qualifies = false;
|
|
break;
|
|
}
|
|
|
|
if (qualifies) {
|
|
await awardBadge(userId, badge._id);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error checking badges:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Award a specific badge to a user
|
|
*/
|
|
async function awardBadge(userId, badgeId) {
|
|
try {
|
|
// Get badge details
|
|
const badge = await Badge.findById(badgeId);
|
|
if (!badge) {
|
|
throw new Error("Badge not found");
|
|
}
|
|
|
|
// Create user badge record
|
|
const userBadge = await UserBadge.create({
|
|
user: userId,
|
|
badge: badgeId,
|
|
earnedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Award points for earning badge (if it's a rare or higher badge)
|
|
let pointsAwarded = 0;
|
|
if (badge.rarity === 'rare') {
|
|
pointsAwarded = 50;
|
|
} else if (badge.rarity === 'epic') {
|
|
pointsAwarded = 100;
|
|
} else if (badge.rarity === 'legendary') {
|
|
pointsAwarded = 200;
|
|
}
|
|
|
|
if (pointsAwarded > 0) {
|
|
await awardPoints(
|
|
userId,
|
|
pointsAwarded,
|
|
'badge_earned',
|
|
`Earned ${badge.name} badge`,
|
|
{ entityType: 'Badge', entityId: badgeId }
|
|
);
|
|
}
|
|
|
|
return userBadge;
|
|
} catch (error) {
|
|
console.error("Error awarding badge:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get user statistics for badge checking
|
|
*/
|
|
async function getUserStats(userId) {
|
|
try {
|
|
const user = await User.findById(userId);
|
|
if (!user) {
|
|
throw new Error("User not found");
|
|
}
|
|
|
|
return {
|
|
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);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
async function getUserBadges(userId) {
|
|
try {
|
|
const userBadges = await UserBadge.findByUser(userId);
|
|
const badges = [];
|
|
|
|
for (const userBadge of userBadges) {
|
|
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,
|
|
earnedAt: userBadge.earnedAt,
|
|
});
|
|
}
|
|
}
|
|
|
|
return badges;
|
|
} catch (error) {
|
|
console.error("Error getting user badges:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Redeem points for a reward
|
|
*/
|
|
async function redeemPoints(userId, rewardId, pointsCost) {
|
|
try {
|
|
const currentPoints = await getUserPoints(userId);
|
|
|
|
if (currentPoints < pointsCost) {
|
|
throw new Error("Insufficient points");
|
|
}
|
|
|
|
// Deduct points
|
|
const result = await awardPoints(
|
|
userId,
|
|
-pointsCost,
|
|
'reward_redemption',
|
|
`Redeemed reward ${rewardId}`,
|
|
{ entityType: 'Reward', entityId: rewardId }
|
|
);
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error("Error redeeming points:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get leaderboard
|
|
*/
|
|
async function getLeaderboard(limit = 10) {
|
|
try {
|
|
// This would typically use a more efficient query
|
|
const users = await User.findAll();
|
|
|
|
// Sort by points (this should be done at database level for efficiency)
|
|
const sortedUsers = users
|
|
.filter(user => user.points > 0)
|
|
.sort((a, b) => b.points - a.points)
|
|
.slice(0, limit);
|
|
|
|
return sortedUsers.map((user, index) => ({
|
|
rank: index + 1,
|
|
userId: user._id,
|
|
username: user.username,
|
|
points: user.points,
|
|
badges: [], // Would populate if needed
|
|
}));
|
|
} catch (error) {
|
|
console.error("Error getting leaderboard:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
getUserTransactionHistory,
|
|
checkAndAwardBadges,
|
|
awardBadge,
|
|
getUserBadges,
|
|
getUserBadgeProgress,
|
|
getUserStats,
|
|
redeemPoints,
|
|
getLeaderboard,
|
|
getGlobalLeaderboard,
|
|
getWeeklyLeaderboard,
|
|
getMonthlyLeaderboard,
|
|
getFriendsLeaderboard,
|
|
getUserLeaderboardPosition,
|
|
getLeaderboardStats,
|
|
POINT_VALUES,
|
|
}; |