Files
adopt-a-street/backend/routes/analytics.js
William Valentin 3e4c730860 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>
2025-11-03 13:53:48 -08:00

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;