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>
548 lines
16 KiB
JavaScript
548 lines
16 KiB
JavaScript
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;
|