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:
547
backend/routes/analytics.js
Normal file
547
backend/routes/analytics.js
Normal file
@@ -0,0 +1,547 @@
|
||||
const express = require("express");
|
||||
const auth = require("../middleware/auth");
|
||||
const { asyncHandler } = require("../middleware/errorHandler");
|
||||
const { getCacheMiddleware, invalidateCacheByPattern } = require("../middleware/cache");
|
||||
const couchdbService = require("../services/couchdbService");
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Parse timeframe parameter to date filter
|
||||
*/
|
||||
const getTimeframeFilter = (timeframe = "all") => {
|
||||
const now = new Date();
|
||||
let startDate = null;
|
||||
|
||||
switch (timeframe) {
|
||||
case "7d":
|
||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case "30d":
|
||||
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case "90d":
|
||||
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case "all":
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return startDate ? startDate.toISOString() : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Group data by time period (day, week, month)
|
||||
*/
|
||||
const groupByTimePeriod = (data, groupBy = "day", dateField = "createdAt") => {
|
||||
const grouped = {};
|
||||
|
||||
data.forEach((item) => {
|
||||
const date = new Date(item[dateField]);
|
||||
let key;
|
||||
|
||||
switch (groupBy) {
|
||||
case "week":
|
||||
const weekStart = new Date(date);
|
||||
weekStart.setDate(date.getDate() - date.getDay());
|
||||
key = weekStart.toISOString().split("T")[0];
|
||||
break;
|
||||
case "month":
|
||||
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
break;
|
||||
case "day":
|
||||
default:
|
||||
key = date.toISOString().split("T")[0];
|
||||
break;
|
||||
}
|
||||
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = [];
|
||||
}
|
||||
grouped[key].push(item);
|
||||
});
|
||||
|
||||
return Object.keys(grouped)
|
||||
.sort()
|
||||
.map((key) => ({
|
||||
period: key,
|
||||
count: grouped[key].length,
|
||||
items: grouped[key],
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/analytics/overview
|
||||
* Get overall platform statistics
|
||||
*/
|
||||
router.get(
|
||||
"/overview",
|
||||
auth,
|
||||
getCacheMiddleware(300), // Cache for 5 minutes
|
||||
asyncHandler(async (req, res) => {
|
||||
const { timeframe = "all" } = req.query;
|
||||
const startDate = getTimeframeFilter(timeframe);
|
||||
|
||||
// Build queries
|
||||
const userQuery = { selector: { type: "user" } };
|
||||
const streetQuery = { selector: { type: "street" } };
|
||||
const taskQuery = { selector: { type: "task" } };
|
||||
const eventQuery = { selector: { type: "event" } };
|
||||
const postQuery = { selector: { type: "post" } };
|
||||
|
||||
// Add timeframe filters if specified
|
||||
if (startDate) {
|
||||
taskQuery.selector.createdAt = { $gte: startDate };
|
||||
eventQuery.selector.createdAt = { $gte: startDate };
|
||||
postQuery.selector.createdAt = { $gte: startDate };
|
||||
}
|
||||
|
||||
// Execute queries in parallel
|
||||
const [users, streets, tasks, events, posts] = await Promise.all([
|
||||
couchdbService.find(userQuery),
|
||||
couchdbService.find(streetQuery),
|
||||
couchdbService.find(taskQuery),
|
||||
couchdbService.find(eventQuery),
|
||||
couchdbService.find(postQuery),
|
||||
]);
|
||||
|
||||
// Calculate statistics
|
||||
const adoptedStreets = streets.filter((s) => s.status === "adopted").length;
|
||||
const completedTasks = tasks.filter((t) => t.status === "completed").length;
|
||||
const activeEvents = events.filter((e) => e.status === "upcoming").length;
|
||||
const totalPoints = users.reduce((sum, user) => sum + (user.points || 0), 0);
|
||||
const averagePointsPerUser = users.length > 0 ? Math.round(totalPoints / users.length) : 0;
|
||||
|
||||
res.json({
|
||||
overview: {
|
||||
totalUsers: users.length,
|
||||
totalStreets: streets.length,
|
||||
adoptedStreets,
|
||||
availableStreets: streets.length - adoptedStreets,
|
||||
totalTasks: tasks.length,
|
||||
completedTasks,
|
||||
pendingTasks: tasks.length - completedTasks,
|
||||
totalEvents: events.length,
|
||||
activeEvents,
|
||||
completedEvents: events.filter((e) => e.status === "completed").length,
|
||||
totalPosts: posts.length,
|
||||
totalPoints,
|
||||
averagePointsPerUser,
|
||||
},
|
||||
timeframe,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/analytics/user/:userId
|
||||
* Get user-specific analytics
|
||||
*/
|
||||
router.get(
|
||||
"/user/:userId",
|
||||
auth,
|
||||
getCacheMiddleware(300), // Cache for 5 minutes
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const { timeframe = "all" } = req.query;
|
||||
const startDate = getTimeframeFilter(timeframe);
|
||||
|
||||
// Get user
|
||||
const user = await couchdbService.findUserById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ msg: "User not found" });
|
||||
}
|
||||
|
||||
// Build queries for user's activity
|
||||
const taskQuery = {
|
||||
selector: {
|
||||
type: "task",
|
||||
"completedBy.userId": userId,
|
||||
},
|
||||
};
|
||||
const postQuery = {
|
||||
selector: {
|
||||
type: "post",
|
||||
"user.userId": userId,
|
||||
},
|
||||
};
|
||||
const eventQuery = {
|
||||
selector: {
|
||||
type: "event",
|
||||
participants: {
|
||||
$elemMatch: { userId: userId },
|
||||
},
|
||||
},
|
||||
};
|
||||
const transactionQuery = {
|
||||
selector: {
|
||||
type: "point_transaction",
|
||||
"user.userId": userId,
|
||||
},
|
||||
};
|
||||
|
||||
// Add timeframe filters if specified
|
||||
if (startDate) {
|
||||
taskQuery.selector.createdAt = { $gte: startDate };
|
||||
postQuery.selector.createdAt = { $gte: startDate };
|
||||
eventQuery.selector.createdAt = { $gte: startDate };
|
||||
transactionQuery.selector.createdAt = { $gte: startDate };
|
||||
}
|
||||
|
||||
// Execute queries in parallel
|
||||
const [tasks, posts, events, transactions] = await Promise.all([
|
||||
couchdbService.find(taskQuery),
|
||||
couchdbService.find(postQuery),
|
||||
couchdbService.find(eventQuery),
|
||||
couchdbService.find(transactionQuery),
|
||||
]);
|
||||
|
||||
// Get adopted streets
|
||||
const adoptedStreetsDetails = await Promise.all(
|
||||
(user.adoptedStreets || []).map((streetId) => couchdbService.getDocument(streetId)),
|
||||
);
|
||||
|
||||
// Calculate points earned/spent
|
||||
const pointsEarned = transactions
|
||||
.filter((t) => t.amount > 0)
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
const pointsSpent = transactions
|
||||
.filter((t) => t.amount < 0)
|
||||
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
|
||||
|
||||
// Calculate engagement metrics
|
||||
const totalLikesReceived = posts.reduce((sum, post) => sum + (post.likesCount || 0), 0);
|
||||
const totalCommentsReceived = posts.reduce((sum, post) => sum + (post.commentsCount || 0), 0);
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user._id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
points: user.points || 0,
|
||||
isPremium: user.isPremium || false,
|
||||
},
|
||||
stats: {
|
||||
streetsAdopted: adoptedStreetsDetails.filter(Boolean).length,
|
||||
tasksCompleted: tasks.length,
|
||||
postsCreated: posts.length,
|
||||
eventsParticipated: events.length,
|
||||
badgesEarned: (user.earnedBadges || []).length,
|
||||
pointsEarned,
|
||||
pointsSpent,
|
||||
totalLikesReceived,
|
||||
totalCommentsReceived,
|
||||
},
|
||||
recentActivity: {
|
||||
tasks: tasks.slice(0, 5),
|
||||
posts: posts.slice(0, 5),
|
||||
events: events.slice(0, 5),
|
||||
},
|
||||
timeframe,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/analytics/activity
|
||||
* Get activity over time
|
||||
*/
|
||||
router.get(
|
||||
"/activity",
|
||||
auth,
|
||||
getCacheMiddleware(300), // Cache for 5 minutes
|
||||
asyncHandler(async (req, res) => {
|
||||
const { timeframe = "30d", groupBy = "day" } = req.query;
|
||||
const startDate = getTimeframeFilter(timeframe);
|
||||
|
||||
// Build queries
|
||||
const taskQuery = { selector: { type: "task" } };
|
||||
const postQuery = { selector: { type: "post" } };
|
||||
const eventQuery = { selector: { type: "event" } };
|
||||
const streetQuery = { selector: { type: "street", status: "adopted" } };
|
||||
|
||||
// Add timeframe filters
|
||||
if (startDate) {
|
||||
taskQuery.selector.createdAt = { $gte: startDate };
|
||||
postQuery.selector.createdAt = { $gte: startDate };
|
||||
eventQuery.selector.createdAt = { $gte: startDate };
|
||||
streetQuery.selector["adoptedBy.userId"] = { $exists: true };
|
||||
}
|
||||
|
||||
// Execute queries in parallel
|
||||
const [tasks, posts, events, streets] = await Promise.all([
|
||||
couchdbService.find(taskQuery),
|
||||
couchdbService.find(postQuery),
|
||||
couchdbService.find(eventQuery),
|
||||
couchdbService.find(streetQuery),
|
||||
]);
|
||||
|
||||
// Filter by timeframe
|
||||
const filterByTimeframe = (items) => {
|
||||
if (!startDate) return items;
|
||||
return items.filter((item) => {
|
||||
const itemDate = new Date(item.createdAt);
|
||||
return itemDate >= new Date(startDate);
|
||||
});
|
||||
};
|
||||
|
||||
const filteredTasks = filterByTimeframe(tasks);
|
||||
const filteredPosts = filterByTimeframe(posts);
|
||||
const filteredEvents = filterByTimeframe(events);
|
||||
const filteredStreets = filterByTimeframe(streets);
|
||||
|
||||
// Group by time period
|
||||
const groupedTasks = groupByTimePeriod(filteredTasks, groupBy);
|
||||
const groupedPosts = groupByTimePeriod(filteredPosts, groupBy);
|
||||
const groupedEvents = groupByTimePeriod(filteredEvents, groupBy);
|
||||
const groupedStreets = groupByTimePeriod(filteredStreets, groupBy);
|
||||
|
||||
// Combine all periods
|
||||
const allPeriods = new Set([
|
||||
...groupedTasks.map((g) => g.period),
|
||||
...groupedPosts.map((g) => g.period),
|
||||
...groupedEvents.map((g) => g.period),
|
||||
...groupedStreets.map((g) => g.period),
|
||||
]);
|
||||
|
||||
const activityData = Array.from(allPeriods)
|
||||
.sort()
|
||||
.map((period) => ({
|
||||
period,
|
||||
tasks: groupedTasks.find((g) => g.period === period)?.count || 0,
|
||||
posts: groupedPosts.find((g) => g.period === period)?.count || 0,
|
||||
events: groupedEvents.find((g) => g.period === period)?.count || 0,
|
||||
streetsAdopted: groupedStreets.find((g) => g.period === period)?.count || 0,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
activity: activityData,
|
||||
timeframe,
|
||||
groupBy,
|
||||
summary: {
|
||||
totalTasks: filteredTasks.length,
|
||||
totalPosts: filteredPosts.length,
|
||||
totalEvents: filteredEvents.length,
|
||||
totalStreetsAdopted: filteredStreets.length,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/analytics/top-contributors
|
||||
* Get top contributing users
|
||||
*/
|
||||
router.get(
|
||||
"/top-contributors",
|
||||
auth,
|
||||
getCacheMiddleware(300), // Cache for 5 minutes
|
||||
asyncHandler(async (req, res) => {
|
||||
const { limit = 10, timeframe = "all", metric = "points" } = req.query;
|
||||
const startDate = getTimeframeFilter(timeframe);
|
||||
|
||||
// Get all users
|
||||
const users = await couchdbService.find({
|
||||
selector: { type: "user" },
|
||||
});
|
||||
|
||||
// If timeframe is specified, calculate contributions within that timeframe
|
||||
let contributors;
|
||||
|
||||
if (startDate && metric !== "points") {
|
||||
// For time-based metrics, query activities
|
||||
const contributorsWithActivity = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const taskQuery = {
|
||||
selector: {
|
||||
type: "task",
|
||||
"completedBy.userId": user._id,
|
||||
createdAt: { $gte: startDate },
|
||||
},
|
||||
};
|
||||
const postQuery = {
|
||||
selector: {
|
||||
type: "post",
|
||||
"user.userId": user._id,
|
||||
createdAt: { $gte: startDate },
|
||||
},
|
||||
};
|
||||
const streetQuery = {
|
||||
selector: {
|
||||
type: "street",
|
||||
"adoptedBy.userId": user._id,
|
||||
},
|
||||
};
|
||||
|
||||
const [tasks, posts, streets] = await Promise.all([
|
||||
couchdbService.find(taskQuery),
|
||||
couchdbService.find(postQuery),
|
||||
couchdbService.find(streetQuery),
|
||||
]);
|
||||
|
||||
let score = 0;
|
||||
switch (metric) {
|
||||
case "tasks":
|
||||
score = tasks.length;
|
||||
break;
|
||||
case "posts":
|
||||
score = posts.length;
|
||||
break;
|
||||
case "streets":
|
||||
score = streets.length;
|
||||
break;
|
||||
default:
|
||||
score = user.points || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: user._id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
profilePicture: user.profilePicture,
|
||||
isPremium: user.isPremium,
|
||||
score,
|
||||
stats: {
|
||||
points: user.points || 0,
|
||||
tasksCompleted: tasks.length,
|
||||
postsCreated: posts.length,
|
||||
streetsAdopted: streets.length,
|
||||
badgesEarned: (user.earnedBadges || []).length,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
contributors = contributorsWithActivity
|
||||
.filter((c) => c.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, parseInt(limit));
|
||||
} else {
|
||||
// For all-time or points metric, use user data directly
|
||||
contributors = users
|
||||
.map((user) => {
|
||||
let score = 0;
|
||||
switch (metric) {
|
||||
case "tasks":
|
||||
score = user.stats?.tasksCompleted || 0;
|
||||
break;
|
||||
case "posts":
|
||||
score = user.stats?.postsCreated || 0;
|
||||
break;
|
||||
case "streets":
|
||||
score = user.stats?.streetsAdopted || 0;
|
||||
break;
|
||||
default:
|
||||
score = user.points || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: user._id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
profilePicture: user.profilePicture,
|
||||
isPremium: user.isPremium,
|
||||
score,
|
||||
stats: {
|
||||
points: user.points || 0,
|
||||
tasksCompleted: user.stats?.tasksCompleted || 0,
|
||||
postsCreated: user.stats?.postsCreated || 0,
|
||||
streetsAdopted: user.stats?.streetsAdopted || 0,
|
||||
badgesEarned: (user.earnedBadges || []).length,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((c) => c.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, parseInt(limit));
|
||||
}
|
||||
|
||||
res.json({
|
||||
contributors,
|
||||
metric,
|
||||
timeframe,
|
||||
limit: parseInt(limit),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/analytics/street-stats
|
||||
* Get street adoption and task completion statistics
|
||||
*/
|
||||
router.get(
|
||||
"/street-stats",
|
||||
auth,
|
||||
getCacheMiddleware(300), // Cache for 5 minutes
|
||||
asyncHandler(async (req, res) => {
|
||||
const { timeframe = "all" } = req.query;
|
||||
const startDate = getTimeframeFilter(timeframe);
|
||||
|
||||
// Get all streets
|
||||
const streets = await couchdbService.find({
|
||||
selector: { type: "street" },
|
||||
});
|
||||
|
||||
// Get all tasks
|
||||
const taskQuery = { selector: { type: "task" } };
|
||||
if (startDate) {
|
||||
taskQuery.selector.createdAt = { $gte: startDate };
|
||||
}
|
||||
const tasks = await couchdbService.find(taskQuery);
|
||||
|
||||
// Calculate street statistics
|
||||
const totalStreets = streets.length;
|
||||
const adoptedStreets = streets.filter((s) => s.status === "adopted").length;
|
||||
const availableStreets = streets.filter((s) => s.status === "available").length;
|
||||
|
||||
const adoptionRate = totalStreets > 0 ? ((adoptedStreets / totalStreets) * 100).toFixed(2) : 0;
|
||||
|
||||
// Task statistics
|
||||
const totalTasks = tasks.length;
|
||||
const completedTasks = tasks.filter((t) => t.status === "completed").length;
|
||||
const pendingTasks = tasks.filter((t) => t.status === "pending").length;
|
||||
const inProgressTasks = tasks.filter((t) => t.status === "in_progress").length;
|
||||
|
||||
const completionRate = totalTasks > 0 ? ((completedTasks / totalTasks) * 100).toFixed(2) : 0;
|
||||
|
||||
// Top streets by task completion
|
||||
const streetTaskCounts = {};
|
||||
tasks
|
||||
.filter((t) => t.status === "completed" && t.street?.streetId)
|
||||
.forEach((task) => {
|
||||
const streetId = task.street.streetId;
|
||||
if (!streetTaskCounts[streetId]) {
|
||||
streetTaskCounts[streetId] = {
|
||||
streetId,
|
||||
streetName: task.street.name,
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
streetTaskCounts[streetId].count++;
|
||||
});
|
||||
|
||||
const topStreets = Object.values(streetTaskCounts)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
res.json({
|
||||
adoption: {
|
||||
totalStreets,
|
||||
adoptedStreets,
|
||||
availableStreets,
|
||||
adoptionRate: parseFloat(adoptionRate),
|
||||
},
|
||||
tasks: {
|
||||
totalTasks,
|
||||
completedTasks,
|
||||
pendingTasks,
|
||||
inProgressTasks,
|
||||
completionRate: parseFloat(completionRate),
|
||||
},
|
||||
topStreets,
|
||||
timeframe,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user