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
+14
View File
@@ -1,4 +1,18 @@
// This file runs before any modules are loaded
// Set test environment variables FIRST (before any module loads)
// Must be at least 32 chars for validation
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-jwt-secret-for-testing-purposes-that-is-long-enough';
process.env.COUCHDB_URL = 'http://localhost:5984';
process.env.COUCHDB_DB_NAME = 'test-adopt-a-street';
process.env.PORT = '5001';
// Mock dotenv to prevent .env file from overriding test values
jest.mock('dotenv', () => ({
config: jest.fn()
}));
// Mock axios first since couchdbService uses it
jest.mock('axios', () => ({
create: jest.fn(() => ({
+488
View File
@@ -0,0 +1,488 @@
const request = require("supertest");
const { app } = require("../../server");
const couchdbService = require("../../services/couchdbService");
const jwt = require("jsonwebtoken");
// Mock couchdbService
jest.mock("../../services/couchdbService");
describe("Analytics API", () => {
let authToken;
let mockUser;
beforeAll(() => {
// Create a mock user and token
mockUser = {
_id: "user_123",
type: "user",
name: "Test User",
email: "test@test.com",
points: 100,
isPremium: false,
adoptedStreets: ["street_1"],
completedTasks: ["task_1", "task_2"],
posts: ["post_1"],
events: ["event_1"],
earnedBadges: [{ badgeId: "badge_1" }],
stats: {
streetsAdopted: 1,
tasksCompleted: 2,
postsCreated: 1,
eventsParticipated: 1,
badgesEarned: 1,
},
};
authToken = jwt.sign({ id: mockUser._id }, process.env.JWT_SECRET || "test_secret");
// Mock couchdbService methods
couchdbService.find = jest.fn();
couchdbService.findUserById = jest.fn();
couchdbService.getDocument = jest.fn();
});
afterEach(() => {
jest.clearAllMocks();
});
describe("GET /api/analytics/overview", () => {
it("should return overview statistics without auth token", async () => {
const mockUsers = [
{ type: "user", points: 100 },
{ type: "user", points: 150 },
];
const mockStreets = [
{ type: "street", status: "adopted" },
{ type: "street", status: "available" },
{ type: "street", status: "adopted" },
];
const mockTasks = [
{ type: "task", status: "completed" },
{ type: "task", status: "pending" },
];
const mockEvents = [
{ type: "event", status: "upcoming" },
{ type: "event", status: "completed" },
];
const mockPosts = [{ type: "post" }, { type: "post" }];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "user") return mockUsers;
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "post") return mockPosts;
return [];
});
const res = await request(app)
.get("/api/analytics/overview")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("overview");
expect(res.body.overview).toHaveProperty("totalUsers", 2);
expect(res.body.overview).toHaveProperty("totalStreets", 3);
expect(res.body.overview).toHaveProperty("adoptedStreets", 2);
expect(res.body.overview).toHaveProperty("totalTasks", 2);
expect(res.body.overview).toHaveProperty("completedTasks", 1);
expect(res.body.overview).toHaveProperty("totalEvents", 2);
expect(res.body.overview).toHaveProperty("totalPosts", 2);
expect(res.body.overview).toHaveProperty("totalPoints", 250);
expect(res.body.overview).toHaveProperty("averagePointsPerUser", 125);
});
it("should filter by timeframe", async () => {
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const mockUsers = [{ type: "user", points: 100 }];
const mockStreets = [{ type: "street", status: "adopted" }];
const mockTasks = [
{ type: "task", status: "completed", createdAt: now.toISOString() },
];
const mockEvents = [
{ type: "event", status: "upcoming", createdAt: now.toISOString() },
];
const mockPosts = [{ type: "post", createdAt: now.toISOString() }];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "user") return mockUsers;
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "post") return mockPosts;
return [];
});
const res = await request(app)
.get("/api/analytics/overview?timeframe=7d")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.timeframe).toBe("7d");
});
it("should require authentication", async () => {
const res = await request(app).get("/api/analytics/overview");
expect(res.statusCode).toBe(401);
});
});
describe("GET /api/analytics/user/:userId", () => {
it("should return user-specific analytics", async () => {
couchdbService.findUserById.mockResolvedValue(mockUser);
couchdbService.getDocument.mockResolvedValue({
_id: "street_1",
name: "Main Street",
});
const mockTasks = [
{ type: "task", completedBy: { userId: mockUser._id }, createdAt: new Date().toISOString() },
];
const mockPosts = [
{ type: "post", user: { userId: mockUser._id }, likesCount: 5, commentsCount: 3, createdAt: new Date().toISOString() },
];
const mockEvents = [
{ type: "event", participants: [{ userId: mockUser._id }], createdAt: new Date().toISOString() },
];
const mockTransactions = [
{ type: "point_transaction", user: { userId: mockUser._id }, amount: 50, createdAt: new Date().toISOString() },
{ type: "point_transaction", user: { userId: mockUser._id }, amount: -20, createdAt: new Date().toISOString() },
];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "post") return mockPosts;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "point_transaction") return mockTransactions;
return [];
});
const res = await request(app)
.get(`/api/analytics/user/${mockUser._id}`)
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("user");
expect(res.body.user.name).toBe("Test User");
expect(res.body).toHaveProperty("stats");
expect(res.body.stats).toHaveProperty("streetsAdopted");
expect(res.body.stats).toHaveProperty("tasksCompleted");
expect(res.body.stats).toHaveProperty("pointsEarned", 50);
expect(res.body.stats).toHaveProperty("pointsSpent", 20);
expect(res.body.stats).toHaveProperty("totalLikesReceived", 5);
expect(res.body.stats).toHaveProperty("totalCommentsReceived", 3);
expect(res.body).toHaveProperty("recentActivity");
});
it("should return 404 for non-existent user", async () => {
couchdbService.findUserById.mockResolvedValue(null);
const res = await request(app)
.get("/api/analytics/user/invalid_user_id")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(404);
expect(res.body.msg).toBe("User not found");
});
it("should require authentication", async () => {
const res = await request(app).get(`/api/analytics/user/${mockUser._id}`);
expect(res.statusCode).toBe(401);
});
});
describe("GET /api/analytics/activity", () => {
it("should return activity data grouped by day", async () => {
const today = new Date().toISOString();
const mockTasks = [{ type: "task", createdAt: today }];
const mockPosts = [{ type: "post", createdAt: today }];
const mockEvents = [{ type: "event", createdAt: today }];
const mockStreets = [{ type: "street", status: "adopted", createdAt: today }];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "post") return mockPosts;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "street") return mockStreets;
return [];
});
const res = await request(app)
.get("/api/analytics/activity?groupBy=day")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("activity");
expect(res.body).toHaveProperty("groupBy", "day");
expect(res.body).toHaveProperty("summary");
expect(Array.isArray(res.body.activity)).toBe(true);
});
it("should group activity by week", async () => {
const today = new Date().toISOString();
const mockTasks = [{ type: "task", createdAt: today }];
const mockPosts = [];
const mockEvents = [];
const mockStreets = [];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "post") return mockPosts;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "street") return mockStreets;
return [];
});
const res = await request(app)
.get("/api/analytics/activity?groupBy=week")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.groupBy).toBe("week");
});
it("should group activity by month", async () => {
const today = new Date().toISOString();
const mockTasks = [{ type: "task", createdAt: today }];
const mockPosts = [];
const mockEvents = [];
const mockStreets = [];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "post") return mockPosts;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "street") return mockStreets;
return [];
});
const res = await request(app)
.get("/api/analytics/activity?groupBy=month")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.groupBy).toBe("month");
});
it("should require authentication", async () => {
const res = await request(app).get("/api/analytics/activity");
expect(res.statusCode).toBe(401);
});
});
describe("GET /api/analytics/top-contributors", () => {
it("should return top contributors by points", async () => {
const mockUsers = [
{
_id: "user_1",
name: "User 1",
email: "user1@test.com",
points: 500,
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
earnedBadges: [],
},
{
_id: "user_2",
name: "User 2",
email: "user2@test.com",
points: 300,
stats: { tasksCompleted: 7, postsCreated: 3, streetsAdopted: 1 },
earnedBadges: [],
},
];
couchdbService.find.mockResolvedValue(mockUsers);
const res = await request(app)
.get("/api/analytics/top-contributors?limit=5")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("contributors");
expect(res.body.contributors.length).toBeLessThanOrEqual(5);
expect(res.body.metric).toBe("points");
// Verify sorted by score descending
if (res.body.contributors.length > 1) {
expect(res.body.contributors[0].score).toBeGreaterThanOrEqual(
res.body.contributors[1].score
);
}
});
it("should return top contributors by tasks", async () => {
const mockUsers = [
{
_id: "user_1",
name: "User 1",
email: "user1@test.com",
points: 500,
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
earnedBadges: [],
},
];
couchdbService.find.mockResolvedValue(mockUsers);
const res = await request(app)
.get("/api/analytics/top-contributors?metric=tasks")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.metric).toBe("tasks");
});
it("should return top contributors by posts", async () => {
const mockUsers = [
{
_id: "user_1",
name: "User 1",
email: "user1@test.com",
points: 500,
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
earnedBadges: [],
},
];
couchdbService.find.mockResolvedValue(mockUsers);
const res = await request(app)
.get("/api/analytics/top-contributors?metric=posts")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.metric).toBe("posts");
});
it("should return top contributors by streets", async () => {
const mockUsers = [
{
_id: "user_1",
name: "User 1",
email: "user1@test.com",
points: 500,
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
earnedBadges: [],
},
];
couchdbService.find.mockResolvedValue(mockUsers);
const res = await request(app)
.get("/api/analytics/top-contributors?metric=streets")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.metric).toBe("streets");
});
it("should require authentication", async () => {
const res = await request(app).get("/api/analytics/top-contributors");
expect(res.statusCode).toBe(401);
});
});
describe("GET /api/analytics/street-stats", () => {
it("should return street adoption and task statistics", async () => {
const mockStreets = [
{ type: "street", status: "adopted" },
{ type: "street", status: "available" },
{ type: "street", status: "adopted" },
];
const mockTasks = [
{
type: "task",
status: "completed",
street: { streetId: "street_1", name: "Main St" },
},
{
type: "task",
status: "completed",
street: { streetId: "street_1", name: "Main St" },
},
{ type: "task", status: "pending", street: { streetId: "street_2", name: "Oak St" } },
];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
return [];
});
const res = await request(app)
.get("/api/analytics/street-stats")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("adoption");
expect(res.body.adoption).toHaveProperty("totalStreets", 3);
expect(res.body.adoption).toHaveProperty("adoptedStreets", 2);
expect(res.body.adoption).toHaveProperty("availableStreets", 1);
expect(res.body.adoption).toHaveProperty("adoptionRate");
expect(res.body).toHaveProperty("tasks");
expect(res.body.tasks).toHaveProperty("totalTasks", 3);
expect(res.body.tasks).toHaveProperty("completedTasks", 2);
expect(res.body.tasks).toHaveProperty("pendingTasks", 1);
expect(res.body.tasks).toHaveProperty("completionRate");
expect(res.body).toHaveProperty("topStreets");
expect(Array.isArray(res.body.topStreets)).toBe(true);
});
it("should calculate correct adoption rate", async () => {
const mockStreets = [
{ type: "street", status: "adopted" },
{ type: "street", status: "adopted" },
{ type: "street", status: "available" },
{ type: "street", status: "available" },
];
const mockTasks = [];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
return [];
});
const res = await request(app)
.get("/api/analytics/street-stats")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.adoption.adoptionRate).toBe(50.0);
});
it("should calculate correct task completion rate", async () => {
const mockStreets = [];
const mockTasks = [
{ type: "task", status: "completed" },
{ type: "task", status: "completed" },
{ type: "task", status: "completed" },
{ type: "task", status: "pending" },
];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
return [];
});
const res = await request(app)
.get("/api/analytics/street-stats")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.tasks.completionRate).toBe(75.0);
});
it("should require authentication", async () => {
const res = await request(app).get("/api/analytics/street-stats");
expect(res.statusCode).toBe(401);
});
});
});
@@ -0,0 +1,396 @@
const request = require("supertest");
const { app, server } = require("../../server");
const couchdbService = require("../../services/couchdbService");
const User = require("../../models/User");
const PointTransaction = require("../../models/PointTransaction");
const Badge = require("../../models/Badge");
const UserBadge = require("../../models/UserBadge");
const jwt = require("jsonwebtoken");
const { clearCache } = require("../../middleware/cache");
describe("Leaderboard Routes", () => {
let testUsers = [];
let authToken;
let testUserId;
beforeAll(async () => {
await couchdbService.initialize();
});
beforeEach(async () => {
// Clear cache before each test
clearCache();
// Create test users with varying points
const userPromises = [];
for (let i = 0; i < 10; i++) {
const userData = {
name: `Test User ${i}`,
email: `testuser${i}@example.com`,
password: "password123",
points: (10 - i) * 100, // 1000, 900, 800, ..., 100
adoptedStreets: [],
completedTasks: [],
stats: {
streetsAdopted: i,
tasksCompleted: i * 2,
postsCreated: i,
eventsParticipated: i,
badgesEarned: 0
}
};
userPromises.push(User.create(userData));
}
testUsers = await Promise.all(userPromises);
// Set test user ID and create auth token
testUserId = testUsers[0]._id;
authToken = jwt.sign({ id: testUserId }, process.env.JWT_SECRET, {
expiresIn: "1h"
});
// Create some point transactions for weekly/monthly leaderboards
const now = new Date();
const transactionPromises = [];
// Create transactions for this week
for (let i = 0; i < 5; i++) {
const weeklyTransaction = {
user: testUsers[i]._id,
amount: (5 - i) * 50,
transactionType: "earned",
description: `Weekly test transaction ${i}`,
balanceAfter: testUsers[i].points + (5 - i) * 50,
createdAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000).toISOString()
};
transactionPromises.push(PointTransaction.create(weeklyTransaction));
}
// Create transactions for this month (but not this week)
for (let i = 5; i < 8; i++) {
const monthlyTransaction = {
user: testUsers[i]._id,
amount: (8 - i) * 30,
transactionType: "earned",
description: `Monthly test transaction ${i}`,
balanceAfter: testUsers[i].points + (8 - i) * 30,
createdAt: new Date(now.getTime() - (i + 5) * 24 * 60 * 60 * 1000).toISOString()
};
transactionPromises.push(PointTransaction.create(monthlyTransaction));
}
await Promise.all(transactionPromises);
});
afterEach(async () => {
// Clean up test data
const deletePromises = [];
// Delete test users
for (const user of testUsers) {
if (user._id && user._rev) {
deletePromises.push(
couchdbService.deleteDocument(user._id, user._rev).catch(() => {})
);
}
}
// Delete test transactions
const transactions = await couchdbService.find({
selector: { type: "point_transaction" }
});
for (const transaction of transactions) {
deletePromises.push(
couchdbService.deleteDocument(transaction._id, transaction._rev).catch(() => {})
);
}
await Promise.all(deletePromises);
testUsers = [];
clearCache();
});
afterAll(async () => {
await new Promise((resolve) => {
server.close(resolve);
});
});
describe("GET /api/leaderboard/global", () => {
it("should get global leaderboard with top users", async () => {
const res = await request(app)
.get("/api/leaderboard/global")
.expect(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
expect(res.body.data.length).toBeGreaterThan(0);
expect(res.body.data.length).toBeLessThanOrEqual(100);
// Check first user has highest points
const firstUser = res.body.data[0];
expect(firstUser).toHaveProperty("rank", 1);
expect(firstUser).toHaveProperty("userId");
expect(firstUser).toHaveProperty("username");
expect(firstUser).toHaveProperty("points");
expect(firstUser).toHaveProperty("streetsAdopted");
expect(firstUser).toHaveProperty("tasksCompleted");
expect(firstUser).toHaveProperty("badges");
// Verify sorting by points descending
for (let i = 0; i < res.body.data.length - 1; i++) {
expect(res.body.data[i].points).toBeGreaterThanOrEqual(
res.body.data[i + 1].points
);
}
});
it("should respect limit parameter", async () => {
const res = await request(app)
.get("/api/leaderboard/global?limit=5")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data.length).toBeLessThanOrEqual(5);
expect(res.body.limit).toBe(5);
});
it("should respect offset parameter", async () => {
const res1 = await request(app)
.get("/api/leaderboard/global?limit=5")
.expect(200);
const res2 = await request(app)
.get("/api/leaderboard/global?limit=5&offset=5")
.expect(200);
expect(res2.body.success).toBe(true);
expect(res2.body.offset).toBe(5);
// First user in second page should have lower points than last in first page
if (res2.body.data.length > 0 && res1.body.data.length === 5) {
expect(res2.body.data[0].points).toBeLessThanOrEqual(
res1.body.data[4].points
);
}
});
it("should cache leaderboard results", async () => {
const res1 = await request(app)
.get("/api/leaderboard/global")
.expect(200);
const res2 = await request(app)
.get("/api/leaderboard/global")
.expect(200);
expect(res1.body).toEqual(res2.body);
});
it("should limit maximum request to 500", async () => {
const res = await request(app)
.get("/api/leaderboard/global?limit=1000")
.expect(200);
expect(res.body.limit).toBe(500);
});
});
describe("GET /api/leaderboard/weekly", () => {
it("should get weekly leaderboard", async () => {
const res = await request(app)
.get("/api/leaderboard/weekly")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.timeframe).toBe("week");
expect(Array.isArray(res.body.data)).toBe(true);
});
it("should only include points from this week", async () => {
const res = await request(app)
.get("/api/leaderboard/weekly")
.expect(200);
expect(res.body.success).toBe(true);
// Weekly leaderboard should have users with weekly transactions
if (res.body.data.length > 0) {
const firstUser = res.body.data[0];
expect(firstUser).toHaveProperty("points");
expect(firstUser).toHaveProperty("rank", 1);
}
});
it("should respect limit and offset", async () => {
const res = await request(app)
.get("/api/leaderboard/weekly?limit=3&offset=1")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.limit).toBe(3);
expect(res.body.offset).toBe(1);
});
});
describe("GET /api/leaderboard/monthly", () => {
it("should get monthly leaderboard", async () => {
const res = await request(app)
.get("/api/leaderboard/monthly")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.timeframe).toBe("month");
expect(Array.isArray(res.body.data)).toBe(true);
});
it("should only include points from this month", async () => {
const res = await request(app)
.get("/api/leaderboard/monthly")
.expect(200);
expect(res.body.success).toBe(true);
// Monthly leaderboard should have users with monthly transactions
if (res.body.data.length > 0) {
const firstUser = res.body.data[0];
expect(firstUser).toHaveProperty("points");
expect(firstUser).toHaveProperty("rank", 1);
}
});
});
describe("GET /api/leaderboard/friends", () => {
it("should require authentication", async () => {
const res = await request(app)
.get("/api/leaderboard/friends")
.expect(401);
expect(res.body.success).toBe(false);
});
it("should get friends leaderboard with authentication", async () => {
const res = await request(app)
.get("/api/leaderboard/friends")
.set("x-auth-token", authToken)
.expect(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
});
it("should include user if no friends", async () => {
const res = await request(app)
.get("/api/leaderboard/friends")
.set("x-auth-token", authToken)
.expect(200);
expect(res.body.success).toBe(true);
// Should at least include the user themselves
const userEntry = res.body.data.find(entry => entry.userId === testUserId);
if (res.body.data.length > 0) {
expect(userEntry || res.body.data.length === 1).toBeTruthy();
}
});
});
describe("GET /api/leaderboard/user/:userId", () => {
it("should get user leaderboard position", async () => {
const res = await request(app)
.get(`/api/leaderboard/user/${testUserId}`)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveProperty("rank");
expect(res.body.data).toHaveProperty("userId", testUserId);
expect(res.body.data).toHaveProperty("username");
expect(res.body.data).toHaveProperty("points");
expect(res.body.data).toHaveProperty("totalUsers");
expect(res.body.data).toHaveProperty("percentile");
});
it("should support timeframe parameter", async () => {
const res = await request(app)
.get(`/api/leaderboard/user/${testUserId}?timeframe=week`)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveProperty("rank");
});
it("should return 404 for non-existent user", async () => {
const res = await request(app)
.get("/api/leaderboard/user/nonexistent_user_id")
.expect(404);
expect(res.body.success).toBe(false);
});
});
describe("GET /api/leaderboard/stats", () => {
it("should get leaderboard statistics", async () => {
const res = await request(app)
.get("/api/leaderboard/stats")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveProperty("totalUsers");
expect(res.body.data).toHaveProperty("totalPoints");
expect(res.body.data).toHaveProperty("avgPoints");
expect(res.body.data).toHaveProperty("maxPoints");
expect(res.body.data).toHaveProperty("minPoints");
expect(res.body.data).toHaveProperty("weeklyStats");
expect(res.body.data.weeklyStats).toHaveProperty("totalPoints");
expect(res.body.data.weeklyStats).toHaveProperty("activeUsers");
expect(res.body.data.weeklyStats).toHaveProperty("transactions");
});
it("should cache statistics", async () => {
const res1 = await request(app)
.get("/api/leaderboard/stats")
.expect(200);
const res2 = await request(app)
.get("/api/leaderboard/stats")
.expect(200);
expect(res1.body).toEqual(res2.body);
});
});
describe("Edge Cases", () => {
it("should handle empty leaderboard gracefully", async () => {
// Delete all users first
const deletePromises = testUsers.map(user =>
couchdbService.deleteDocument(user._id, user._rev).catch(() => {})
);
await Promise.all(deletePromises);
clearCache();
const res = await request(app)
.get("/api/leaderboard/global")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toEqual([]);
});
it("should handle invalid limit gracefully", async () => {
const res = await request(app)
.get("/api/leaderboard/global?limit=invalid")
.expect(200);
expect(res.body.success).toBe(true);
expect(typeof res.body.limit).toBe("number");
});
it("should handle negative offset gracefully", async () => {
const res = await request(app)
.get("/api/leaderboard/global?offset=-5")
.expect(200);
expect(res.body.success).toBe(true);
});
});
});
@@ -0,0 +1,52 @@
const { body, validationResult } = require("express-validator");
const URL_REGEX = /^(https?|ftp):\\/\\/[^\\s\\/$.?#].[^\\s]*$/i;
const validateProfile = [
body("bio")
.optional()
.isLength({ max: 500 })
.withMessage("Bio cannot exceed 500 characters."),
body("location").optional().isString(),
body("website")
.optional()
.if(body("website").notEmpty())
.matches(URL_REGEX)
.withMessage("Invalid website URL."),
body("social.twitter")
.optional()
.if(body("social.twitter").notEmpty())
.matches(URL_REGEX)
.withMessage("Invalid Twitter URL."),
body("social.github")
.optional()
.if(body("social.github").notEmpty())
.matches(URL_REGEX)
.withMessage("Invalid Github URL."),
body("social.linkedin")
.optional()
.if(body("social.linkedin").notEmpty())
.matches(URL_REGEX)
.withMessage("Invalid LinkedIn URL."),
body("privacySettings.profileVisibility")
.optional()
.isIn(["public", "private"])
.withMessage("Profile visibility must be public or private."),
body("preferences.emailNotifications").optional().isBoolean(),
body("preferences.pushNotifications").optional().isBoolean(),
body("preferences.theme")
.optional()
.isIn(["light", "dark"])
.withMessage("Theme must be light or dark."),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
},
];
module.exports = { validateProfile };
+62 -95
View File
@@ -2,24 +2,18 @@ const bcrypt = require("bcryptjs");
const couchdbService = require("../services/couchdbService");
const {
ValidationError,
NotFoundError,
DatabaseError,
DuplicateError,
withErrorHandling,
createErrorContext
createErrorContext,
} = require("../utils/modelErrors");
const URL_REGEX = /^(https?|ftp):\/\/[^\s\/$.?#].[^\s]*$/i;
class User {
constructor(data) {
// Validate required fields
if (!data.name) {
throw new ValidationError('Name is required', 'name', data.name);
}
if (!data.email) {
throw new ValidationError('Email is required', 'email', data.email);
}
if (!data.password) {
throw new ValidationError('Password is required', 'password', data.password);
constructor(data) { // Validate required fields for new user creation
if (!data._id) { // Only for new users
if (!data.name) { throw new ValidationError("Name is required", "name", data.name); }
if (!data.email) { throw new ValidationError("Email is required", "email", data.email); }
if (!data.password) { throw new ValidationError("Password is required", "password", data.password); }
}
this._id = data._id || null;
@@ -28,39 +22,60 @@ class User {
this.name = data.name;
this.email = data.email;
this.password = data.password;
// --- Profile Information ---
this.avatar = data.avatar || null;
this.cloudinaryPublicId = data.cloudinaryPublicId || null;
this.bio = data.bio || "";
if (this.bio.length > 510) { throw new ValidationError("Bio cannot exceed 500 characters.", "bio", this.bio); }
this.location = data.location || "";
this.website = data.website || "";
if (this.website && !URL_REGEX.test(this.website)) { throw new ValidationError("Invalid website URL.", "website", this.website); }
// --- Social Links ---
this.social = data.social || { twitter: "", github: "", linkedin: "" };
if (this.social.twitter && !URL_REGEX.test(this.social.twitter)) { throw new ValidationError("Invalid Twitter URL.", "social.twitter", this.social.twitter); }
if (this.social.github && !URL_REGEX.test(this.social.github)) { throw new ValidationError("Invalid Github URL.", "social.github", this.social.github); }
if (this.social.linkedin && !URL_REGEX.test(this.social.linkedin)) { throw new ValidationError("Invalid LinkedIn URL.", "social.linkedin", this.social.linkedin); }
// --- Settings & Preferences ---
this.privacySettings = data.privacySettings || { profileVisibility: "public" };
if (!["public", "private"].includes(this.privacySettings.profileVisibility)) { throw new ValidationError("Profile visibility must be 'public' or 'private.", "privacySettings.profileVisibility", this.privacySettings.profileVisibility); }
this.preferences = data.preferences || { emailNotifications: true, pushNotifications: true, theme: "light" };
if (!["light", "dark"].includes(this.preferences.theme)) { throw new ValidationError("Theme must be light' or 'dark'.", "preferences.theme", this.preferences.theme); }
// --- Gamification & App Data ---
this.isPremium = data.isPremium || false;
this.points = Math.max(0, data.points || 0); // Ensure non-negative
this.points = Math.max(0, data.points || 0);
this.adoptedStreets = data.adoptedStreets || [];
this.completedTasks = data.completedTasks || [];
this.posts = data.posts || [];
this.events = data.events || [];
this.profilePicture = data.profilePicture || null;
this.cloudinaryPublicId = data.cloudinaryPublicId || null;
this.earnedBadges = data.earnedBadges || [];
this.stats = data.stats || {
streetsAdopted: 0,
tasksCompleted: 0,
postsCreated: 0,
eventsParticipated: 0,
badgesEarned: 0
badgesEarned: 0,
};
this.createdAt = data.createdAt || new Date().toISOString();
this.updatedAt = data.updatedAt || new Date().toISOString();
}
// ... (static methods remain the same)
// Static methods for MongoDB compatibility
static async findOne(query) {
const errorContext = createErrorContext('User', 'findOne', { query });
return await withErrorHandling(async () => {
let user;
if (query.email) {
user = await couchdbService.findUserByEmail(query.email);
} else if (query._id) {
user = await couchdbService.findUserById(query._id);
} else {
// Generic query fallback
const docs = await couchdbService.find({
if (query.email) { user = await couchdbService.findUserByEmail(query.email); }
else if (query._id) { user = await couchdeService.findUserById(query._id); }
else { // Generic query fallback
const docs = await couchdeService.find({
selector: { type: "user", ...query },
limit: 1
});
@@ -74,7 +89,7 @@ class User {
const errorContext = createErrorContext('User', 'findById', { id });
return await withErrorHandling(async () => {
const user = await couchdbService.findUserById(id);
const user = await couchdeService.findUserById(id);
return user ? new User(user) : null;
}, errorContext);
}
@@ -83,16 +98,14 @@ class User {
const errorContext = createErrorContext('User', 'findByIdAndUpdate', { id, update, options });
return await withErrorHandling(async () => {
const user = await couchdbService.findUserById(id);
const user = await couchdeService.findUserById(id);
if (!user) return null;
const updatedUser = { ...user, ...update, updatedAt: new Date().toISOString() };
const saved = await couchdbService.update(id, updatedUser);
const saved = await couchdeService.update(id, updatedUser);
if (options.new) {
return saved;
}
return user;
if (options.new) { return new User(saved); }
return new User(user);
}, errorContext);
}
@@ -100,7 +113,7 @@ class User {
const errorContext = createErrorContext('User', 'findByIdAndDelete', { id });
return await withErrorHandling(async () => {
const user = await couchdbService.findUserById(id);
const user = await couchdeService.findUserById(id);
if (!user) return null;
await couchdbService.delete(id);
@@ -113,7 +126,8 @@ class User {
return await withErrorHandling(async () => {
const selector = { type: "user", ...query };
return await couchdbService.find({ selector });
const users = await couchdbService.find({ selector });
return users.map(u => new User(u));
}, errorContext);
}
@@ -123,23 +137,15 @@ class User {
return await withErrorHandling(async () => {
const user = new User(userData);
// Hash password if provided
if (user.password) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
if (user.password) { const salt = await bcrypt.genSalt(10); user.password = await bcrypt.hash(user.password, salt); }
// Generate ID if not provided
if (!user._id) {
user._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
if (!user._id) { user._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; }
const created = await couchdbService.createDocument(user.toJSON());
return new User(created);
}, errorContext);
}
// Instance methods
async save() {
const errorContext = createErrorContext('User', 'save', {
id: this._id,
@@ -148,23 +154,18 @@ class User {
});
return await withErrorHandling(async () => {
this.updatedAt = new Date().toISOString();
if (!this._id) {
// New document
this._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Hash password if not already hashed
if (this.password && !this.password.startsWith('$2')) {
if (this.password && !this.password.startsWith('$2)')) {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
}
const created = await couchdbService.createDocument(this.toJSON());
this._rev = created._rev;
return this;
} else {
// Update existing document
this.updatedAt = new Date().toISOString();
const updated = await couchdbService.updateDocument(this.toJSON());
const updated = await couchdeService.updateDocument(this.toJSON());
this._rev = updated._rev;
return this;
}
@@ -182,14 +183,12 @@ class User {
}, errorContext);
}
// Helper method to get user without password
toSafeObject() {
const obj = this.toJSON();
delete obj.password;
return obj;
}
// Convert to CouchDB document format
toJSON() {
return {
_id: this._id,
@@ -198,58 +197,26 @@ class User {
name: this.name,
email: this.email,
password: this.password,
avatar: this.avatar,
cloudinaryPublicId: this.cloudinaryPublicId,
bio: this.bio,
location: this.location,
website: this.website,
social: this.social,
privacySettings: this.privacySettings,
preferences: this.preferences,
isPremium: this.isPremium,
points: this.points,
adoptedStreets: this.adoptedStreets,
completedTasks: this.completedTasks,
posts: this.posts,
events: this.events,
profilePicture: this.profilePicture,
cloudinaryPublicId: this.cloudinaryPublicId,
earnedBadges: this.earnedBadges,
stats: this.stats,
createdAt: this.createdAt,
updatedAt: this.updatedAt
updatedAt: this.updatedAt,
};
}
// Static method for select functionality
static async select(fields) {
const errorContext = createErrorContext('User', 'select', { fields });
return await withErrorHandling(async () => {
const users = await couchdbService.find({
selector: { type: "user" },
fields: fields
});
return users.map(user => new User(user));
}, errorContext);
}
}
// Add select method to instance for chaining
User.prototype.select = function(fields) {
const obj = this.toJSON();
const selected = {};
if (fields.includes('-password')) {
// Exclude password
fields = fields.filter(f => f !== '-password');
fields.forEach(field => {
if (obj[field] !== undefined) {
selected[field] = obj[field];
}
});
} else {
// Include only specified fields
fields.forEach(field => {
if (obj[field] !== undefined) {
selected[field] = obj[field];
}
});
}
return selected;
};
module.exports = User;
+547
View 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;
+8 -3
View File
@@ -4,6 +4,7 @@ const UserBadge = require("../models/UserBadge");
const auth = require("../middleware/auth");
const { asyncHandler } = require("../middleware/errorHandler");
const { getUserBadgeProgress } = require("../services/gamificationService");
const { getCacheMiddleware } = require("../middleware/cache");
const router = express.Router();
@@ -13,8 +14,9 @@ const router = express.Router();
*/
router.get(
"/",
getCacheMiddleware(600), // 10 minute cache
asyncHandler(async (req, res) => {
const badges = await Badge.find({ type: "badge" });
const badges = await Badge.findAll();
// Sort by order and rarity in JavaScript since CouchDB doesn't support complex sorting
badges.sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
@@ -31,6 +33,7 @@ router.get(
router.get(
"/progress",
auth,
getCacheMiddleware(600), // 10 minute cache
asyncHandler(async (req, res) => {
const progress = await getUserBadgeProgress(req.user.id);
res.json(progress);
@@ -38,11 +41,12 @@ router.get(
);
/**
* GET /api/badges/users/:userId
* Get badges earned by a specific user
* GET /api/users/:userId/badges
* Get badges earned by a specific user with progress
*/
router.get(
"/users/:userId",
getCacheMiddleware(600), // 10 minute cache
asyncHandler(async (req, res) => {
const { userId } = req.params;
@@ -67,6 +71,7 @@ router.get(
*/
router.get(
"/:badgeId",
getCacheMiddleware(600), // 10 minute cache
asyncHandler(async (req, res) => {
const { badgeId } = req.params;
+200
View File
@@ -0,0 +1,200 @@
const express = require("express");
const router = express.Router();
const auth = require("../middleware/auth");
const { getCacheMiddleware, invalidateCacheByPattern } = require("../middleware/cache");
const gamificationService = require("../services/gamificationService");
const User = require("../models/User");
const logger = require("../utils/logger");
/**
* @route GET /api/leaderboard/global
* @desc Get global leaderboard (all time)
* @access Public
* @query limit (default 100), offset (default 0)
*/
router.get("/global", getCacheMiddleware(300), async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const offset = parseInt(req.query.offset) || 0;
logger.info("Fetching global leaderboard", { limit, offset });
const leaderboard = await gamificationService.getGlobalLeaderboard(limit, offset);
res.json({
success: true,
count: leaderboard.length,
limit,
offset,
data: leaderboard
});
} catch (error) {
logger.error("Error fetching global leaderboard", error);
res.status(500).json({
success: false,
msg: "Server error fetching global leaderboard",
error: error.message
});
}
});
/**
* @route GET /api/leaderboard/weekly
* @desc Get weekly leaderboard
* @access Public
* @query limit (default 100), offset (default 0)
*/
router.get("/weekly", getCacheMiddleware(300), async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const offset = parseInt(req.query.offset) || 0;
logger.info("Fetching weekly leaderboard", { limit, offset });
const leaderboard = await gamificationService.getWeeklyLeaderboard(limit, offset);
res.json({
success: true,
count: leaderboard.length,
limit,
offset,
timeframe: "week",
data: leaderboard
});
} catch (error) {
logger.error("Error fetching weekly leaderboard", error);
res.status(500).json({
success: false,
msg: "Server error fetching weekly leaderboard",
error: error.message
});
}
});
/**
* @route GET /api/leaderboard/monthly
* @desc Get monthly leaderboard
* @access Public
* @query limit (default 100), offset (default 0)
*/
router.get("/monthly", getCacheMiddleware(300), async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const offset = parseInt(req.query.offset) || 0;
logger.info("Fetching monthly leaderboard", { limit, offset });
const leaderboard = await gamificationService.getMonthlyLeaderboard(limit, offset);
res.json({
success: true,
count: leaderboard.length,
limit,
offset,
timeframe: "month",
data: leaderboard
});
} catch (error) {
logger.error("Error fetching monthly leaderboard", error);
res.status(500).json({
success: false,
msg: "Server error fetching monthly leaderboard",
error: error.message
});
}
});
/**
* @route GET /api/leaderboard/friends
* @desc Get friends leaderboard (requires auth)
* @access Private
* @query limit (default 100), offset (default 0)
*/
router.get("/friends", auth, getCacheMiddleware(300), async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const offset = parseInt(req.query.offset) || 0;
const userId = req.user.id;
logger.info("Fetching friends leaderboard", { userId, limit, offset });
const leaderboard = await gamificationService.getFriendsLeaderboard(userId, limit, offset);
res.json({
success: true,
count: leaderboard.length,
limit,
offset,
data: leaderboard
});
} catch (error) {
logger.error("Error fetching friends leaderboard", error);
res.status(500).json({
success: false,
msg: "Server error fetching friends leaderboard",
error: error.message
});
}
});
/**
* @route GET /api/leaderboard/user/:userId
* @desc Get user's rank and position in leaderboard
* @access Public
*/
router.get("/user/:userId", getCacheMiddleware(300), async (req, res) => {
try {
const { userId } = req.params;
const timeframe = req.query.timeframe || "all"; // all, week, month
logger.info("Fetching user leaderboard position", { userId, timeframe });
const userPosition = await gamificationService.getUserLeaderboardPosition(userId, timeframe);
if (!userPosition) {
return res.status(404).json({
success: false,
msg: "User not found or has no points"
});
}
res.json({
success: true,
data: userPosition
});
} catch (error) {
logger.error("Error fetching user leaderboard position", error);
res.status(500).json({
success: false,
msg: "Server error fetching user position",
error: error.message
});
}
});
/**
* @route GET /api/leaderboard/stats
* @desc Get leaderboard statistics
* @access Public
*/
router.get("/stats", getCacheMiddleware(300), async (req, res) => {
try {
logger.info("Fetching leaderboard statistics");
const stats = await gamificationService.getLeaderboardStats();
res.json({
success: true,
data: stats
});
} catch (error) {
logger.error("Error fetching leaderboard statistics", error);
res.status(500).json({
success: false,
msg: "Server error fetching leaderboard statistics",
error: error.message
});
}
});
module.exports = router;
+126
View File
@@ -0,0 +1,126 @@
const express = require("express");
const User = require("../models/User");
const auth = require("../middleware/auth");
const { asyncHandler } = require("../middleware/errorHandler");
const { upload, handleUploadError } = require("../middleware/upload");
const { uploadImage, deleteImage } = require("../config/cloudinary");
const { validateProfile } = require("../middleware/validators/profileValidator");
const { userIdValidation } = require("../middleware/validators/userValidator");
const router = express.Router();
// GET user profile
router.get(
"/:userId",
auth,
userIdValidation,
asyncHandler(async (req, res) => {
const { userId } = req.params;
const user = await User.findById(userId);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
if (user.privacySettings.profileVisibility === "private" && req.user.id !== userId) {
return res.status(403).json({ msg: "This profile is private" });
}
res.json(user.toSafeObject());
})
);
// PUT update user profile
router.put(
"/",
auth,
validateProfile,
asyncHandler(async (req, res) => {
const userId = req.user.id;
const {
bio,
location,
website,
social,
privacySettings,
preferences,
} = req.body;
const user = await User.findById(userId);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
// Update fields
if (bio !== undefined) user.bio = bio;
if (location !== undefined) user.location = location;
if (website !== undefined) user.website = website;
if (social !== undefined) user.social = { ...user.social, ...social };
if (privacySettings !== undefined) user.privacySettings = { ...user.privacySettings, ...privacySettings };
if (preferences !== undefined) user.preferences = { ...user.preferences, ...preferences };
const updatedUser = await user.save();
res.json(updatedUser.toSafeObject());
})
);
// POST upload avatar
router.post(
"/avatar",
auth,
upload.single("avatar"),
handleUploadError,
asyncHandler(async (req, res) => {
if (!req.file) {
return res.status(400).json({ msg: "No image file provided" });
}
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
if (user.cloudinaryPublicId) {
await deleteImage(user.cloudinaryPublicId);
}
const result = await uploadImage(
req.file.buffer,
"adopt-a-street/avatars"
);
user.avatar = result.secure_url;
user.cloudinaryPublicId = result.public_id;
const updatedUser = await user.save();
res.json({
msg: "Avatar updated successfully",
avatar: updatedUser.avatar
});
})
);
// DELETE remove avatar
router.delete(
"/avatar",
auth,
asyncHandler(async (req, res) => {
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
if (user.cloudinaryPublicId) {
await deleteImage(user.cloudinaryPublicId);
user.avatar = null;
user.cloudinaryPublicId = null;
await user.save();
}
res.json({ msg: "Avatar removed successfully" });
})
);
module.exports = router;
+1 -70
View File
@@ -4,8 +4,6 @@ const Street = require("../models/Street");
const auth = require("../middleware/auth");
const { asyncHandler } = require("../middleware/errorHandler");
const { userIdValidation } = require("../middleware/validators/userValidator");
const { upload, handleUploadError } = require("../middleware/upload");
const { uploadImage, deleteImage } = require("../config/cloudinary");
const router = express.Router();
@@ -38,7 +36,7 @@ router.get(
}
const userWithStreets = {
...user,
...user.toSafeObject(),
adoptedStreets,
};
@@ -46,71 +44,4 @@ router.get(
}),
);
// Upload profile picture
router.post(
"/profile-picture",
auth,
upload.single("image"),
handleUploadError,
asyncHandler(async (req, res) => {
if (!req.file) {
return res.status(400).json({ msg: "No image file provided" });
}
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
// Delete old profile picture if exists
if (user.cloudinaryPublicId) {
await deleteImage(user.cloudinaryPublicId);
}
// Upload new image to Cloudinary
const result = await uploadImage(
req.file.buffer,
"adopt-a-street/profiles",
);
// Update user with new profile picture
const updatedUser = await User.update(req.user.id, {
profilePicture: result.url,
cloudinaryPublicId: result.publicId,
});
res.json({
msg: "Profile picture updated successfully",
profilePicture: updatedUser.profilePicture,
});
}),
);
// Delete profile picture
router.delete(
"/profile-picture",
auth,
asyncHandler(async (req, res) => {
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
if (!user.cloudinaryPublicId) {
return res.status(400).json({ msg: "No profile picture to delete" });
}
// Delete image from Cloudinary
await deleteImage(user.cloudinaryPublicId);
// Remove from user
await User.update(req.user.id, {
profilePicture: undefined,
cloudinaryPublicId: undefined,
});
res.json({ msg: "Profile picture deleted successfully" });
}),
);
module.exports = router;
+3
View File
@@ -137,6 +137,7 @@ const aiRoutes = require("./routes/ai");
const paymentRoutes = require("./routes/payments");
const userRoutes = require("./routes/users");
const cacheRoutes = require("./routes/cache");
const profileRoutes = require("./routes/profile");
// Apply rate limiters
app.use("/api/auth/register", authLimiter);
@@ -238,6 +239,8 @@ app.use("/api/ai", aiRoutes);
app.use("/api/payments", paymentRoutes);
app.use("/api/users", userRoutes);
app.use("/api/cache", cacheRoutes);
app.use("/api/analytics", analyticsRoutes);
app.use("/api/leaderboard", leaderboardRoutes);
app.get("/", (req, res) => {
res.send("Street Adoption App Backend");
+544 -17
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,
};
+392 -3
View File
@@ -14,6 +14,7 @@
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.8.3",
"caniuse-lite": "^1.0.30001753",
"leaflet": "^1.9.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -21,6 +22,7 @@
"react-router-dom": "^6.28.0",
"react-scripts": "5.0.1",
"react-toastify": "^11.0.5",
"recharts": "^3.3.0",
"socket.io-client": "^4.8.1",
"web-vitals": "^2.1.4"
},
@@ -3270,6 +3272,42 @@
"react-dom": "^19.0.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.2.tgz",
"integrity": "sha512-ZAYu/NXkl/OhqTz7rfPaAhY0+e8Fr15jqNxte/2exKUxvHyQ/hcqmdekiN1f+Lcw3pE+34FCgX+26zcUE3duCg==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
@@ -3400,6 +3438,18 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@stripe/stripe-js": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-6.0.0.tgz",
@@ -3855,6 +3905,69 @@
"@types/node": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/eslint": {
"version": "8.56.12",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz",
@@ -4116,6 +4229,12 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz",
@@ -5715,9 +5834,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001704",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001704.tgz",
"integrity": "sha512-+L2IgBbV6gXB4ETf0keSvLr7JUrRVbIaB/lrQ1+z8mRcQiisG5k+lG6O4n6Y5q6f5EuNfaYXKgymucphlEXQew==",
"version": "1.0.30001753",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz",
"integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==",
"funding": [
{
"type": "opencollective",
@@ -6621,6 +6740,127 @@
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -6715,6 +6955,12 @@
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
"license": "MIT"
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/dedent": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
@@ -7416,6 +7662,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.41.0.tgz",
"integrity": "sha512-bDd3oRmbVgqZCJS6WmeQieOrzpl3URcWBUVDXxOELlUW2FuW+0glPOz1n0KnRie+PdyvUZcXz2sOn00c6pPRIA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -9602,6 +9858,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
@@ -14442,6 +14707,29 @@
"react-dom": "^19.0.0"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
@@ -14604,6 +14892,49 @@
"node": ">=8.10.0"
}
},
"node_modules/recharts": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.3.0.tgz",
"integrity": "sha512-Vi0qmTB0iz1+/Cz9o5B7irVyUjX2ynvEgImbgMt/3sKRREcUM07QiYjS1QpAVrkmVlXqy5gykq4nGWMz9AS4Rg==",
"license": "MIT",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts/node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/recharts/node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/recursive-readdir": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz",
@@ -14629,6 +14960,21 @@
"node": ">=8"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -14803,6 +15149,12 @@
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -16730,6 +17082,12 @@
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tldts": {
"version": "7.0.17",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz",
@@ -17203,6 +17561,15 @@
"requires-port": "^1.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -17277,6 +17644,28 @@
"node": ">= 0.8"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/w3c-hr-time": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
+1
View File
@@ -17,6 +17,7 @@
"react-router-dom": "^6.28.0",
"react-scripts": "5.0.1",
"react-toastify": "^11.0.5",
"recharts": "^3.3.0",
"socket.io-client": "^4.8.1",
"web-vitals": "^2.1.4"
},
+4
View File
@@ -15,7 +15,9 @@ import SocialFeed from "./components/SocialFeed";
import Profile from "./components/Profile";
import Events from "./components/Events";
import Rewards from "./components/Rewards";
import Leaderboard from "./components/Leaderboard";
import Premium from "./components/Premium";
import Analytics from "./components/Analytics";
import Navbar from "./components/Navbar";
import PrivateRoute from "./components/PrivateRoute";
@@ -36,7 +38,9 @@ function App() {
<Route path="/profile" element={<PrivateRoute><Profile /></PrivateRoute>} />
<Route path="/events" element={<PrivateRoute><Events /></PrivateRoute>} />
<Route path="/rewards" element={<PrivateRoute><Rewards /></PrivateRoute>} />
<Route path="/leaderboard" element={<PrivateRoute><Leaderboard /></PrivateRoute>} />
<Route path="/premium" element={<PrivateRoute><Premium /></PrivateRoute>} />
<Route path="/analytics" element={<PrivateRoute><Analytics /></PrivateRoute>} />
<Route path="/" element={<Navigate to="/map" replace />} />
</Routes>
</div>
+480
View File
@@ -0,0 +1,480 @@
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import axios from "axios";
import { toast } from "react-toastify";
import Leaderboard from "../components/Leaderboard";
import { AuthContext } from "../context/AuthContext";
// Mock axios
jest.mock("axios");
// Mock react-toastify
jest.mock("react-toastify", () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
warning: jest.fn(),
},
}));
// Mock leaderboard data
const mockLeaderboardData = [
{
userId: "user1",
username: "TopUser",
email: "topuser@example.com",
points: 1000,
streetsAdopted: 5,
tasksCompleted: 20,
badges: [
{ name: "Beginner", icon: "🏅" },
{ name: "Intermediate", icon: "🏆" },
],
},
{
userId: "user2",
username: "SecondUser",
email: "second@example.com",
points: 800,
streetsAdopted: 4,
tasksCompleted: 15,
badges: [{ name: "Beginner", icon: "🏅" }],
},
{
userId: "user3",
username: "ThirdUser",
email: "third@example.com",
points: 600,
streetsAdopted: 3,
tasksCompleted: 10,
badges: [],
},
];
const mockStats = {
totalUsers: 100,
totalPoints: 50000,
averagePoints: 500,
maxPoints: 1000,
minPoints: 0,
};
describe("Leaderboard Component", () => {
const mockAuthContext = {
auth: {
isAuthenticated: true,
user: {
_id: "user1",
username: "TopUser",
points: 1000,
},
},
};
const renderLeaderboard = (authContext = mockAuthContext) => {
return render(
<BrowserRouter>
<AuthContext.Provider value={authContext}>
<Leaderboard />
</AuthContext.Provider>
</BrowserRouter>
);
};
beforeEach(() => {
jest.clearAllMocks();
localStorage.setItem("token", "mock-token");
});
afterEach(() => {
localStorage.clear();
});
describe("Initial Loading", () => {
it("should display loading spinner on initial load", () => {
axios.get.mockImplementation(() => new Promise(() => {})); // Never resolves
renderLeaderboard();
expect(screen.getByText(/loading leaderboard/i)).toBeInTheDocument();
expect(screen.getByRole("status")).toBeInTheDocument();
});
it("should load global leaderboard by default", async () => {
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/global")) {
return Promise.resolve({ data: mockLeaderboardData });
}
if (url.includes("/api/leaderboard/stats")) {
return Promise.resolve({ data: mockStats });
}
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("/api/leaderboard/global"),
expect.anything()
);
});
it("should load leaderboard stats", async () => {
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/global")) {
return Promise.resolve({ data: mockLeaderboardData });
}
if (url.includes("/api/leaderboard/stats")) {
return Promise.resolve({ data: mockStats });
}
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText(/total users:/i)).toBeInTheDocument();
});
expect(screen.getByText(/100/)).toBeInTheDocument();
expect(screen.getByText(/50,000/)).toBeInTheDocument();
});
});
describe("Tab Navigation", () => {
beforeEach(() => {
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: mockLeaderboardData });
}
return Promise.resolve({ data: mockStats });
});
});
it("should switch to weekly leaderboard when clicking weekly tab", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
const weeklyTab = screen.getByRole("button", { name: /this week/i });
fireEvent.click(weeklyTab);
await waitFor(() => {
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("/api/leaderboard/weekly"),
expect.anything()
);
});
});
it("should switch to monthly leaderboard when clicking monthly tab", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
const monthlyTab = screen.getByRole("button", { name: /this month/i });
fireEvent.click(monthlyTab);
await waitFor(() => {
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("/api/leaderboard/monthly"),
expect.anything()
);
});
});
it("should switch to friends leaderboard when authenticated", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
const friendsTab = screen.getByRole("button", { name: /friends/i });
fireEvent.click(friendsTab);
await waitFor(() => {
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("/api/leaderboard/friends"),
expect.objectContaining({
headers: { "x-auth-token": "mock-token" },
})
);
});
});
it("should show warning when trying to access friends tab without authentication", async () => {
const unauthContext = {
auth: {
isAuthenticated: false,
user: null,
},
};
axios.get.mockResolvedValue({ data: mockLeaderboardData });
renderLeaderboard(unauthContext);
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
const friendsTab = screen.getByRole("button", { name: /friends/i });
fireEvent.click(friendsTab);
expect(toast.warning).toHaveBeenCalledWith(
"Please login to view friends leaderboard"
);
});
});
describe("User Display", () => {
beforeEach(() => {
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: mockLeaderboardData });
}
return Promise.resolve({ data: mockStats });
});
});
it("should display all users in leaderboard", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
expect(screen.getByText("SecondUser")).toBeInTheDocument();
expect(screen.getByText("ThirdUser")).toBeInTheDocument();
});
});
it("should highlight current user", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
expect(screen.getByText("You")).toBeInTheDocument();
});
it("should display user points", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("1,000")).toBeInTheDocument();
});
});
it("should display user statistics", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
// Check for streets and tasks counts
const statsElements = screen.getAllByText(/5|20/);
expect(statsElements.length).toBeGreaterThan(0);
});
it("should display current user points in alert", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText(/your points:/i)).toBeInTheDocument();
});
expect(screen.getByText("1000")).toBeInTheDocument();
});
});
describe("Pagination", () => {
beforeEach(() => {
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: mockLeaderboardData });
}
return Promise.resolve({ data: mockStats });
});
});
it("should disable previous button on first page", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
const previousButton = screen.getByRole("button", { name: /previous/i });
expect(previousButton).toBeDisabled();
});
it("should enable next button when there are more results", async () => {
// Mock 50 results to trigger hasMore
const largeDataset = Array.from({ length: 50 }, (_, i) => ({
userId: `user${i}`,
username: `User${i}`,
points: 1000 - i * 10,
streetsAdopted: 1,
tasksCompleted: 1,
badges: [],
}));
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: largeDataset });
}
return Promise.resolve({ data: mockStats });
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("User0")).toBeInTheDocument();
});
const nextButton = screen.getByRole("button", { name: /next/i });
expect(nextButton).not.toBeDisabled();
});
it("should load next page when clicking next button", async () => {
const largeDataset = Array.from({ length: 50 }, (_, i) => ({
userId: `user${i}`,
username: `User${i}`,
points: 1000 - i * 10,
streetsAdopted: 1,
tasksCompleted: 1,
badges: [],
}));
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: largeDataset });
}
return Promise.resolve({ data: mockStats });
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("User0")).toBeInTheDocument();
});
const nextButton = screen.getByRole("button", { name: /next/i });
fireEvent.click(nextButton);
await waitFor(() => {
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("offset=50"),
expect.anything()
);
});
});
});
describe("Error Handling", () => {
it("should display error message when API fails", async () => {
axios.get.mockRejectedValue({
response: { data: { msg: "Server error" } },
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText(/error loading leaderboard/i)).toBeInTheDocument();
});
expect(toast.error).toHaveBeenCalledWith("Server error");
});
it("should show retry button on error", async () => {
axios.get.mockRejectedValue({
response: { data: { msg: "Server error" } },
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
});
it("should retry loading when clicking retry button", async () => {
axios.get
.mockRejectedValueOnce({
response: { data: { msg: "Server error" } },
})
.mockImplementation((url) => {
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: mockLeaderboardData });
}
return Promise.resolve({ data: mockStats });
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
const retryButton = screen.getByRole("button", { name: /retry/i });
fireEvent.click(retryButton);
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
});
});
describe("Empty State", () => {
it("should display message when leaderboard is empty", async () => {
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: [] });
}
return Promise.resolve({ data: mockStats });
});
renderLeaderboard();
await waitFor(() => {
expect(
screen.getByText(/no users to display yet/i)
).toBeInTheDocument();
});
});
it("should display friends-specific message when friends leaderboard is empty", async () => {
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/friends")) {
return Promise.resolve({ data: [] });
}
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: mockLeaderboardData });
}
return Promise.resolve({ data: mockStats });
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
const friendsTab = screen.getByRole("button", { name: /friends/i });
fireEvent.click(friendsTab);
await waitFor(() => {
expect(
screen.getByText(/no friends to display/i)
).toBeInTheDocument();
});
});
});
});
+83
View File
@@ -0,0 +1,83 @@
import React from "react";
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
const ActivityChart = ({ data, groupBy }) => {
// Format period labels based on groupBy
const formatPeriod = (period) => {
if (!period) return "";
if (groupBy === "month") {
const [year, month] = period.split("-");
const date = new Date(year, parseInt(month) - 1);
return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
} else if (groupBy === "week") {
return new Date(period).toLocaleDateString("en-US", { month: "short", day: "numeric" });
} else {
return new Date(period).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
};
const chartData = data.map((item) => ({
period: formatPeriod(item.period),
Tasks: item.tasks,
Posts: item.posts,
Events: item.events,
"Streets Adopted": item.streetsAdopted,
}));
return (
<div>
<div className="mb-4">
<h6>Activity Trends</h6>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="period" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="Tasks" stroke="#0d6efd" strokeWidth={2} />
<Line type="monotone" dataKey="Posts" stroke="#198754" strokeWidth={2} />
<Line type="monotone" dataKey="Events" stroke="#ffc107" strokeWidth={2} />
<Line
type="monotone"
dataKey="Streets Adopted"
stroke="#dc3545"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div>
<h6>Activity Comparison</h6>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="period" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="Tasks" fill="#0d6efd" />
<Bar dataKey="Posts" fill="#198754" />
<Bar dataKey="Events" fill="#ffc107" />
<Bar dataKey="Streets Adopted" fill="#dc3545" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default ActivityChart;
+182
View File
@@ -0,0 +1,182 @@
.analytics-container {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.analytics-header {
margin-bottom: 30px;
}
.analytics-header h2 {
color: #333;
margin-bottom: 10px;
}
.analytics-header p {
color: #666;
font-size: 14px;
}
.analytics-tabs {
display: flex;
gap: 10px;
margin-bottom: 30px;
border-bottom: 2px solid #e0e0e0;
flex-wrap: wrap;
}
.analytics-tab {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 16px;
color: #666;
transition: all 0.3s ease;
margin-bottom: -2px;
}
.analytics-tab:hover {
color: #333;
background-color: #f5f5f5;
}
.analytics-tab.active {
color: #28a745;
border-bottom-color: #28a745;
font-weight: 600;
}
.timeframe-selector {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.timeframe-btn {
padding: 8px 16px;
border: 2px solid #e0e0e0;
background: white;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.3s ease;
}
.timeframe-btn:hover {
border-color: #28a745;
color: #28a745;
}
.timeframe-btn.active {
background-color: #28a745;
color: white;
border-color: #28a745;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.stat-card h3 {
font-size: 14px;
color: #666;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .stat-value {
font-size: 36px;
font-weight: bold;
color: #28a745;
margin-bottom: 5px;
}
.stat-card .stat-label {
font-size: 12px;
color: #999;
}
.charts-section {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
.charts-section h3 {
color: #333;
margin-bottom: 20px;
font-size: 20px;
}
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
padding: 60px;
font-size: 18px;
color: #666;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid #f5c6cb;
}
.no-data {
text-align: center;
padding: 40px;
color: #999;
font-size: 16px;
}
@media (max-width: 768px) {
.analytics-container {
padding: 10px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.stat-card .stat-value {
font-size: 28px;
}
.analytics-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.analytics-tab {
padding: 10px 16px;
font-size: 14px;
}
}
+325
View File
@@ -0,0 +1,325 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import ActivityChart from "./ActivityChart";
import ContributorsList from "./ContributorsList";
import StreetStatsChart from "./StreetStatsChart";
import PersonalStats from "./PersonalStats";
import "./Analytics.css";
const Analytics = () => {
const [overview, setOverview] = useState(null);
const [activity, setActivity] = useState(null);
const [contributors, setContributors] = useState([]);
const [streetStats, setStreetStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [timeframe, setTimeframe] = useState("30d");
const [groupBy, setGroupBy] = useState("day");
const [activeTab, setActiveTab] = useState("overview");
const fetchAnalyticsData = async () => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem("token");
const config = {
headers: {
"x-auth-token": token,
},
};
const [overviewRes, activityRes, contributorsRes, streetStatsRes] = await Promise.all([
axios.get(`/api/analytics/overview?timeframe=${timeframe}`, config),
axios.get(`/api/analytics/activity?timeframe=${timeframe}&groupBy=${groupBy}`, config),
axios.get(`/api/analytics/top-contributors?limit=10&timeframe=${timeframe}`, config),
axios.get(`/api/analytics/street-stats?timeframe=${timeframe}`, config),
]);
setOverview(overviewRes.data.overview);
setActivity(activityRes.data);
setContributors(contributorsRes.data.contributors);
setStreetStats(streetStatsRes.data);
} catch (err) {
console.error("Error fetching analytics:", err);
setError(err.response?.data?.msg || "Failed to load analytics data");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAnalyticsData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeframe, groupBy]);
const handleTimeframeChange = (e) => {
setTimeframe(e.target.value);
};
const handleGroupByChange = (e) => {
setGroupBy(e.target.value);
};
if (loading) {
return (
<div className="analytics-container">
<div className="text-center mt-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p className="mt-3">Loading analytics...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="analytics-container">
<div className="alert alert-danger mt-3" role="alert">
{error}
</div>
</div>
);
}
return (
<div className="analytics-container">
<div className="analytics-header">
<h1>Analytics Dashboard</h1>
<div className="analytics-controls">
<div className="form-group">
<label htmlFor="timeframe">Timeframe:</label>
<select
id="timeframe"
className="form-select"
value={timeframe}
onChange={handleTimeframeChange}
>
<option value="7d">Last 7 Days</option>
<option value="30d">Last 30 Days</option>
<option value="90d">Last 90 Days</option>
<option value="all">All Time</option>
</select>
</div>
{activeTab === "activity" && (
<div className="form-group">
<label htmlFor="groupBy">Group By:</label>
<select
id="groupBy"
className="form-select"
value={groupBy}
onChange={handleGroupByChange}
>
<option value="day">Day</option>
<option value="week">Week</option>
<option value="month">Month</option>
</select>
</div>
)}
</div>
</div>
<ul className="nav nav-tabs mb-4">
<li className="nav-item">
<button
className={`nav-link ${activeTab === "overview" ? "active" : ""}`}
onClick={() => setActiveTab("overview")}
>
Overview
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "activity" ? "active" : ""}`}
onClick={() => setActiveTab("activity")}
>
Activity
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "personal" ? "active" : ""}`}
onClick={() => setActiveTab("personal")}
>
My Stats
</button>
</li>
</ul>
{activeTab === "overview" && overview && (
<div className="overview-tab">
<div className="row">
<div className="col-md-3 mb-4">
<div className="stat-card">
<div className="stat-icon bg-primary">
<i className="fas fa-users"></i>
</div>
<div className="stat-content">
<h3>{overview.totalUsers}</h3>
<p>Total Users</p>
</div>
</div>
</div>
<div className="col-md-3 mb-4">
<div className="stat-card">
<div className="stat-icon bg-success">
<i className="fas fa-road"></i>
</div>
<div className="stat-content">
<h3>{overview.adoptedStreets}</h3>
<p>Streets Adopted</p>
<small className="text-muted">
{overview.availableStreets} available
</small>
</div>
</div>
</div>
<div className="col-md-3 mb-4">
<div className="stat-card">
<div className="stat-icon bg-info">
<i className="fas fa-tasks"></i>
</div>
<div className="stat-content">
<h3>{overview.completedTasks}</h3>
<p>Tasks Completed</p>
<small className="text-muted">
{overview.pendingTasks} pending
</small>
</div>
</div>
</div>
<div className="col-md-3 mb-4">
<div className="stat-card">
<div className="stat-icon bg-warning">
<i className="fas fa-calendar"></i>
</div>
<div className="stat-content">
<h3>{overview.activeEvents}</h3>
<p>Active Events</p>
<small className="text-muted">
{overview.completedEvents} completed
</small>
</div>
</div>
</div>
</div>
<div className="row mt-4">
<div className="col-md-3 mb-4">
<div className="stat-card">
<div className="stat-icon bg-secondary">
<i className="fas fa-comments"></i>
</div>
<div className="stat-content">
<h3>{overview.totalPosts}</h3>
<p>Total Posts</p>
</div>
</div>
</div>
<div className="col-md-3 mb-4">
<div className="stat-card">
<div className="stat-icon bg-danger">
<i className="fas fa-star"></i>
</div>
<div className="stat-content">
<h3>{overview.totalPoints.toLocaleString()}</h3>
<p>Total Points</p>
</div>
</div>
</div>
<div className="col-md-3 mb-4">
<div className="stat-card">
<div className="stat-icon bg-dark">
<i className="fas fa-chart-line"></i>
</div>
<div className="stat-content">
<h3>{overview.averagePointsPerUser}</h3>
<p>Avg Points/User</p>
</div>
</div>
</div>
</div>
<div className="row mt-4">
<div className="col-lg-8 mb-4">
<div className="card">
<div className="card-header">
<h5>Street Statistics</h5>
</div>
<div className="card-body">
{streetStats && <StreetStatsChart data={streetStats} />}
</div>
</div>
</div>
<div className="col-lg-4 mb-4">
<div className="card">
<div className="card-header">
<h5>Top Contributors</h5>
</div>
<div className="card-body">
<ContributorsList contributors={contributors} />
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === "activity" && activity && (
<div className="activity-tab">
<div className="card">
<div className="card-header">
<h5>Activity Over Time</h5>
</div>
<div className="card-body">
<ActivityChart data={activity.activity} groupBy={groupBy} />
</div>
</div>
<div className="row mt-4">
<div className="col-md-3">
<div className="stat-card small">
<h4>{activity.summary.totalTasks}</h4>
<p>Total Tasks</p>
</div>
</div>
<div className="col-md-3">
<div className="stat-card small">
<h4>{activity.summary.totalPosts}</h4>
<p>Total Posts</p>
</div>
</div>
<div className="col-md-3">
<div className="stat-card small">
<h4>{activity.summary.totalEvents}</h4>
<p>Total Events</p>
</div>
</div>
<div className="col-md-3">
<div className="stat-card small">
<h4>{activity.summary.totalStreetsAdopted}</h4>
<p>Streets Adopted</p>
</div>
</div>
</div>
</div>
)}
{activeTab === "personal" && (
<div className="personal-tab">
<PersonalStats timeframe={timeframe} />
</div>
)}
</div>
);
};
export default Analytics;
+188
View File
@@ -0,0 +1,188 @@
import React, { useState, useEffect, useContext } from "react";
import axios from "axios";
import { toast } from "react-toastify";
import BadgeDisplay from "./BadgeDisplay";
import { AuthContext } from "../context/AuthContext";
/**
* BadgeCollection component - displays all available badges with filter/sort options
*/
const BadgeCollection = () => {
const { auth } = useContext(AuthContext);
const [badges, setBadges] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filter, setFilter] = useState("all"); // all, earned, locked
const [sortBy, setSortBy] = useState("rarity"); // rarity, name
useEffect(() => {
const fetchBadges = async () => {
try {
setLoading(true);
setError(null);
// Fetch all badges and user's progress
const [allBadgesRes, progressRes] = await Promise.all([
axios.get("/api/badges"),
axios.get("/api/badges/progress"),
]);
// Merge badge data with progress data
const badgesWithProgress = allBadgesRes.data.map((badge) => {
const progress = progressRes.data.find((p) => p._id === badge._id);
return {
...badge,
isEarned: progress?.isEarned || false,
progress: progress?.progress || 0,
threshold: progress?.threshold || badge.criteria?.threshold || 0,
};
});
setBadges(badgesWithProgress);
} catch (err) {
console.error("Error fetching badges:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to load badges";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
if (auth.isAuthenticated) {
fetchBadges();
}
}, [auth.isAuthenticated]);
// Filter badges based on selected filter
const getFilteredBadges = () => {
let filtered = [...badges];
if (filter === "earned") {
filtered = filtered.filter((badge) => badge.isEarned);
} else if (filter === "locked") {
filtered = filtered.filter((badge) => !badge.isEarned);
}
return filtered;
};
// Sort badges based on selected sort option
const getSortedBadges = () => {
const filtered = getFilteredBadges();
if (sortBy === "rarity") {
const rarityOrder = { legendary: 0, epic: 1, rare: 2, common: 3 };
return filtered.sort(
(a, b) => rarityOrder[a.rarity] - rarityOrder[b.rarity]
);
} else if (sortBy === "name") {
return filtered.sort((a, b) => a.name.localeCompare(b.name));
}
return filtered;
};
const sortedBadges = getSortedBadges();
const earnedCount = badges.filter((b) => b.isEarned).length;
const totalCount = badges.length;
if (loading) {
return (
<div className="text-center mt-5">
<div className="spinner-border" role="status">
<span className="sr-only">Loading...</span>
</div>
<p>Loading badges...</p>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger m-3" role="alert">
<h4 className="alert-heading">Error Loading Badges</h4>
<p>{error}</p>
</div>
);
}
if (!auth.isAuthenticated) {
return (
<div className="alert alert-warning m-3" role="alert">
<h4 className="alert-heading">Not Logged In</h4>
<p>Please log in to view badges.</p>
</div>
);
}
return (
<div className="badge-collection">
<div className="d-flex justify-content-between align-items-center mb-4">
<h2>Badge Collection</h2>
<div>
<span className="badge badge-primary badge-lg">
{earnedCount} / {totalCount} Earned
</span>
</div>
</div>
{/* Filter and Sort Controls */}
<div className="card mb-4">
<div className="card-body">
<div className="row">
<div className="col-md-6">
<label htmlFor="filterSelect" className="form-label">
<strong>Filter:</strong>
</label>
<select
id="filterSelect"
className="form-control"
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
<option value="all">All Badges</option>
<option value="earned">Earned Only</option>
<option value="locked">Locked Only</option>
</select>
</div>
<div className="col-md-6">
<label htmlFor="sortSelect" className="form-label">
<strong>Sort By:</strong>
</label>
<select
id="sortSelect"
className="form-control"
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
>
<option value="rarity">Rarity</option>
<option value="name">Name</option>
</select>
</div>
</div>
</div>
</div>
{/* Badge Grid */}
{sortedBadges.length === 0 ? (
<div className="alert alert-info">
<p className="mb-0">No badges found with the selected filter.</p>
</div>
) : (
<div className="row">
{sortedBadges.map((badge) => (
<div key={badge._id} className="col-md-4 col-lg-3 mb-4">
<BadgeDisplay badge={badge} isEarned={badge.isEarned} />
</div>
))}
</div>
)}
</div>
);
};
export default BadgeCollection;
+88
View File
@@ -0,0 +1,88 @@
import React from "react";
import PropTypes from "prop-types";
/**
* BadgeDisplay component - displays a single badge with icon, name, and description
* @param {Object} badge - Badge object
* @param {boolean} isEarned - Whether the badge is earned
* @param {boolean} showTooltip - Whether to show tooltip on hover
*/
const BadgeDisplay = ({ badge, isEarned = false, showTooltip = true }) => {
const getRarityColor = (rarity) => {
switch (rarity) {
case "common":
return "#6c757d";
case "rare":
return "#0d6efd";
case "epic":
return "#6f42c1";
case "legendary":
return "#ffc107";
default:
return "#6c757d";
}
};
const badgeStyle = {
filter: isEarned ? "none" : "grayscale(100%) opacity(0.5)",
borderColor: getRarityColor(badge.rarity),
borderWidth: "3px",
transition: "all 0.3s ease",
};
const iconStyle = {
fontSize: "3rem",
marginBottom: "0.5rem",
};
return (
<div
className="card badge-card text-center p-3"
style={badgeStyle}
title={
showTooltip
? `${badge.name} - ${badge.description}\n${
badge.criteria?.type
? `Unlock: ${badge.criteria.threshold} ${badge.criteria.type.replace(
"_",
" "
)}`
: ""
}`
: ""
}
>
<div style={iconStyle}>{badge.icon || "🏆"}</div>
<h6 className="mb-1">{badge.name}</h6>
<p className="small text-muted mb-1">{badge.description}</p>
<span
className={`badge badge-${badge.rarity === "legendary" ? "warning" : badge.rarity === "epic" ? "purple" : badge.rarity === "rare" ? "primary" : "secondary"}`}
>
{badge.rarity}
</span>
{isEarned && (
<div className="mt-2">
<span className="badge badge-success"> Earned</span>
</div>
)}
</div>
);
};
BadgeDisplay.propTypes = {
badge: PropTypes.shape({
_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
icon: PropTypes.string,
rarity: PropTypes.oneOf(["common", "rare", "epic", "legendary"]).isRequired,
criteria: PropTypes.shape({
type: PropTypes.string,
threshold: PropTypes.number,
}),
}).isRequired,
isEarned: PropTypes.bool,
showTooltip: PropTypes.bool,
};
export default BadgeDisplay;
+79
View File
@@ -0,0 +1,79 @@
import React from "react";
import PropTypes from "prop-types";
/**
* BadgeProgress component - displays progress bars for badges in progress
* @param {Array} badges - Array of badge objects with progress information
*/
const BadgeProgress = ({ badges }) => {
// Filter to show only badges that are in progress (not earned and have some progress)
const inProgressBadges = badges.filter(
(badge) => !badge.isEarned && badge.progress > 0 && badge.threshold > 0
);
if (inProgressBadges.length === 0) {
return (
<div className="alert alert-info">
<p className="mb-0">
Complete tasks and participate in events to earn badges!
</p>
</div>
);
}
return (
<div className="badge-progress-container">
<h5 className="mb-3">Badges In Progress</h5>
{inProgressBadges.map((badge) => {
const percentage = Math.round((badge.progress / badge.threshold) * 100);
return (
<div key={badge._id} className="mb-3">
<div className="d-flex justify-content-between align-items-center mb-1">
<div className="d-flex align-items-center">
<span style={{ fontSize: "1.5rem", marginRight: "0.5rem" }}>
{badge.icon || "🏆"}
</span>
<div>
<strong>{badge.name}</strong>
<br />
<small className="text-muted">{badge.description}</small>
</div>
</div>
<span className="badge badge-info">
{badge.progress} / {badge.threshold}
</span>
</div>
<div className="progress" style={{ height: "20px" }}>
<div
className={`progress-bar ${percentage >= 75 ? "bg-success" : percentage >= 50 ? "bg-info" : "bg-warning"}`}
role="progressbar"
style={{ width: `${percentage}%` }}
aria-valuenow={badge.progress}
aria-valuemin="0"
aria-valuemax={badge.threshold}
>
{percentage}%
</div>
</div>
</div>
);
})}
</div>
);
};
BadgeProgress.propTypes = {
badges: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
icon: PropTypes.string,
progress: PropTypes.number.isRequired,
threshold: PropTypes.number.isRequired,
isEarned: PropTypes.bool.isRequired,
})
).isRequired,
};
export default BadgeProgress;
@@ -0,0 +1,159 @@
.contributors-list {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
.contributors-list h3 {
color: #333;
margin-bottom: 20px;
font-size: 20px;
}
.metric-selector {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.metric-btn {
padding: 8px 16px;
border: 2px solid #e0e0e0;
background: white;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.3s ease;
text-transform: capitalize;
}
.metric-btn:hover {
border-color: #28a745;
color: #28a745;
}
.metric-btn.active {
background-color: #28a745;
color: white;
border-color: #28a745;
}
.contributors-table {
width: 100%;
border-collapse: collapse;
}
.contributors-table thead {
background-color: #f8f9fa;
}
.contributors-table th {
padding: 12px;
text-align: left;
font-weight: 600;
color: #666;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid #e0e0e0;
}
.contributors-table td {
padding: 15px 12px;
border-bottom: 1px solid #f0f0f0;
}
.contributors-table tbody tr {
transition: background-color 0.3s ease;
}
.contributors-table tbody tr:hover {
background-color: #f8f9fa;
}
.rank-cell {
font-weight: bold;
color: #999;
width: 60px;
}
.rank-cell.rank-1 {
color: #ffd700;
font-size: 18px;
}
.rank-cell.rank-2 {
color: #c0c0c0;
font-size: 16px;
}
.rank-cell.rank-3 {
color: #cd7f32;
font-size: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #28a745;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
}
.user-name {
font-weight: 500;
color: #333;
}
.value-cell {
font-weight: 600;
color: #28a745;
font-size: 16px;
}
.no-contributors {
text-align: center;
padding: 30px;
color: #999;
font-size: 14px;
}
@media (max-width: 768px) {
.contributors-list {
padding: 15px;
}
.contributors-table {
font-size: 14px;
}
.contributors-table th,
.contributors-table td {
padding: 10px 8px;
}
.user-avatar {
width: 32px;
height: 32px;
font-size: 14px;
}
.rank-cell {
width: 40px;
}
}
@@ -0,0 +1,50 @@
import React from "react";
import "./ContributorsList.css";
const ContributorsList = ({ contributors }) => {
if (!contributors || contributors.length === 0) {
return <p className="text-muted">No contributors data available.</p>;
}
return (
<div className="contributors-list">
{contributors.map((contributor, index) => (
<div key={contributor.userId} className="contributor-item">
<div className="contributor-rank">#{index + 1}</div>
<div className="contributor-avatar">
{contributor.profilePicture ? (
<img
src={contributor.profilePicture}
alt={contributor.name}
className="avatar-img"
/>
) : (
<div className="avatar-placeholder">
{contributor.name.charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="contributor-info">
<div className="contributor-name">
{contributor.name}
{contributor.isPremium && (
<span className="badge bg-warning text-dark ms-2">Premium</span>
)}
</div>
<div className="contributor-stats">
<small className="text-muted">
{contributor.stats.points} pts | {contributor.stats.tasksCompleted} tasks |{" "}
{contributor.stats.streetsAdopted} streets
</small>
</div>
</div>
<div className="contributor-score">
<strong>{contributor.score}</strong>
</div>
</div>
))}
</div>
);
};
export default ContributorsList;
+263
View File
@@ -0,0 +1,263 @@
import React, { useState, useEffect, useContext, useCallback } from "react";
import axios from "axios";
import { toast } from "react-toastify";
import { AuthContext } from "../context/AuthContext";
import LeaderboardCard from "./LeaderboardCard";
/**
* Leaderboard component displays top users by points with different timeframes
*/
const Leaderboard = () => {
const { auth } = useContext(AuthContext);
const [activeTab, setActiveTab] = useState("global");
const [leaderboard, setLeaderboard] = useState([]);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const limit = 50;
// Load leaderboard data based on active tab
const loadLeaderboard = useCallback(async () => {
try {
setLoading(true);
setError(null);
const offset = (page - 1) * limit;
let endpoint = "";
switch (activeTab) {
case "global":
endpoint = `/api/leaderboard/global?limit=${limit}&offset=${offset}`;
break;
case "weekly":
endpoint = `/api/leaderboard/weekly?limit=${limit}&offset=${offset}`;
break;
case "monthly":
endpoint = `/api/leaderboard/monthly?limit=${limit}&offset=${offset}`;
break;
case "friends":
endpoint = `/api/leaderboard/friends?limit=${limit}&offset=${offset}`;
break;
default:
endpoint = `/api/leaderboard/global?limit=${limit}&offset=${offset}`;
}
const config = {};
// Friends endpoint requires authentication
if (activeTab === "friends") {
const token = localStorage.getItem("token");
config.headers = { "x-auth-token": token };
}
const res = await axios.get(endpoint, config);
setLeaderboard(res.data);
setHasMore(res.data.length === limit);
} catch (err) {
console.error("Error loading leaderboard:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to load leaderboard. Please try again later.";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
}, [activeTab, page]);
// Load leaderboard stats
const loadStats = useCallback(async () => {
try {
const res = await axios.get("/api/leaderboard/stats");
setStats(res.data);
} catch (err) {
console.error("Error loading leaderboard stats:", err);
}
}, []);
useEffect(() => {
loadLeaderboard();
}, [loadLeaderboard]);
useEffect(() => {
loadStats();
}, [loadStats]);
// Handle tab change
const handleTabChange = (tab) => {
if (tab === "friends" && !auth.isAuthenticated) {
toast.warning("Please login to view friends leaderboard");
return;
}
setActiveTab(tab);
setPage(1);
setLeaderboard([]);
};
// Handle pagination
const handlePreviousPage = () => {
if (page > 1) {
setPage(page - 1);
}
};
const handleNextPage = () => {
if (hasMore) {
setPage(page + 1);
}
};
if (loading && leaderboard.length === 0) {
return (
<div className="text-center mt-5">
<div className="spinner-border" role="status">
<span className="sr-only">Loading...</span>
</div>
<p>Loading leaderboard...</p>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger m-3" role="alert">
<h4 className="alert-heading">Error Loading Leaderboard</h4>
<p>{error}</p>
<hr />
<button className="btn btn-primary" onClick={loadLeaderboard}>
Retry
</button>
</div>
);
}
return (
<div>
<h1>Leaderboard</h1>
{/* Leaderboard Stats */}
{stats && (
<div className="alert alert-info mb-4">
<div className="row">
<div className="col-md-3 col-6 mb-2">
<strong>Total Users:</strong> {stats.totalUsers}
</div>
<div className="col-md-3 col-6 mb-2">
<strong>Total Points:</strong> {stats.totalPoints.toLocaleString()}
</div>
<div className="col-md-3 col-6 mb-2">
<strong>Avg Points:</strong> {Math.round(stats.averagePoints)}
</div>
<div className="col-md-3 col-6 mb-2">
<strong>Top Score:</strong> {stats.maxPoints.toLocaleString()}
</div>
</div>
</div>
)}
{/* Tab Navigation */}
<ul className="nav nav-tabs mb-4">
<li className="nav-item">
<button
className={`nav-link ${activeTab === "global" ? "active" : ""}`}
onClick={() => handleTabChange("global")}
>
All Time
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "weekly" ? "active" : ""}`}
onClick={() => handleTabChange("weekly")}
>
This Week
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "monthly" ? "active" : ""}`}
onClick={() => handleTabChange("monthly")}
>
This Month
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "friends" ? "active" : ""}`}
onClick={() => handleTabChange("friends")}
disabled={!auth.isAuthenticated}
>
Friends {!auth.isAuthenticated && "(Login Required)"}
</button>
</li>
</ul>
{/* Current User Info */}
{auth.user && activeTab !== "friends" && (
<div className="alert alert-success mb-4">
<strong>Your Points:</strong>{" "}
<span className="badge badge-primary">{auth.user.points || 0}</span>
</div>
)}
{/* Leaderboard List */}
{leaderboard.length === 0 ? (
<div className="alert alert-info">
{activeTab === "friends"
? "No friends to display. Add friends to see them on the leaderboard!"
: "No users to display yet. Be the first to earn points!"}
</div>
) : (
<>
<div className="row">
{leaderboard.map((entry, index) => (
<LeaderboardCard
key={entry.userId}
entry={entry}
rank={(page - 1) * limit + index + 1}
isCurrentUser={auth.user && entry.userId === auth.user._id}
/>
))}
</div>
{/* Pagination */}
<div className="d-flex justify-content-between align-items-center mt-4">
<button
className="btn btn-secondary"
onClick={handlePreviousPage}
disabled={page === 1 || loading}
>
Previous
</button>
<span>
Page {page} {hasMore && "- More available"}
</span>
<button
className="btn btn-secondary"
onClick={handleNextPage}
disabled={!hasMore || loading}
>
{loading ? (
<>
<span
className="spinner-border spinner-border-sm mr-2"
role="status"
aria-hidden="true"
></span>
Loading...
</>
) : (
"Next"
)}
</button>
</div>
</>
)}
</div>
);
};
export default Leaderboard;
+103
View File
@@ -0,0 +1,103 @@
import React from "react";
/**
* LeaderboardCard component displays individual user entry on the leaderboard
* @param {Object} entry - Leaderboard entry data
* @param {Number} rank - User's rank/position
* @param {Boolean} isCurrentUser - Whether this is the logged-in user
*/
const LeaderboardCard = ({ entry, rank, isCurrentUser }) => {
// Determine rank badge color
const getRankBadgeClass = () => {
if (rank === 1) return "badge-warning"; // Gold
if (rank === 2) return "badge-secondary"; // Silver
if (rank === 3) return "badge-danger"; // Bronze
return "badge-primary"; // Default
};
// Get rank emoji
const getRankEmoji = () => {
if (rank === 1) return "🥇";
if (rank === 2) return "🥈";
if (rank === 3) return "🥉";
return "";
};
return (
<div className="col-12 mb-3">
<div
className={`card h-100 ${isCurrentUser ? "border-success" : ""}`}
style={isCurrentUser ? { borderWidth: "3px" } : {}}
>
<div className="card-body">
<div className="row align-items-center">
{/* Rank */}
<div className="col-2 col-md-1 text-center">
<h3 className="mb-0">
<span className={`badge ${getRankBadgeClass()}`}>
{getRankEmoji()} #{rank}
</span>
</h3>
</div>
{/* User Info */}
<div className="col-10 col-md-5">
<h5 className="mb-1">
{entry.username || "Unknown User"}
{isCurrentUser && (
<span className="badge badge-success ml-2">You</span>
)}
</h5>
<div className="text-muted small">
{entry.email && <div>{entry.email}</div>}
</div>
</div>
{/* Points */}
<div className="col-6 col-md-2 text-center mt-2 mt-md-0">
<div className="text-muted small">Points</div>
<h4 className="mb-0 text-primary">
{entry.points.toLocaleString()}
</h4>
</div>
{/* Stats */}
<div className="col-6 col-md-2 text-center mt-2 mt-md-0">
<div className="text-muted small">Streets</div>
<div className="font-weight-bold">{entry.streetsAdopted || 0}</div>
<div className="text-muted small mt-1">Tasks</div>
<div className="font-weight-bold">{entry.tasksCompleted || 0}</div>
</div>
{/* Badges */}
<div className="col-12 col-md-2 text-center mt-2 mt-md-0">
<div className="text-muted small">Badges</div>
<div className="d-flex justify-content-center flex-wrap">
{entry.badges && entry.badges.length > 0 ? (
entry.badges.slice(0, 5).map((badge, index) => (
<span
key={index}
className="badge badge-info mr-1 mb-1"
title={badge.name}
>
{badge.icon || "🏆"}
</span>
))
) : (
<span className="text-muted small">None</span>
)}
{entry.badges && entry.badges.length > 5 && (
<span className="badge badge-secondary ml-1">
+{entry.badges.length - 5}
</span>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default LeaderboardCard;
+6
View File
@@ -22,6 +22,12 @@ const Navbar = () => {
<li className="nav-item">
<Link className="nav-link" to="/rewards">Rewards</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/leaderboard">Leaderboard</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/analytics">Analytics</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/profile">Profile</Link>
</li>
+190
View File
@@ -0,0 +1,190 @@
.personal-stats {
padding: 20px 0;
}
.personal-stats-header {
text-align: center;
margin-bottom: 30px;
}
.personal-stats-header h3 {
color: #333;
font-size: 24px;
margin-bottom: 10px;
}
.personal-stats-header p {
color: #666;
font-size: 14px;
}
.personal-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.personal-stat-card {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
border-radius: 12px;
padding: 24px;
color: white;
box-shadow: 0 4px 6px rgba(40, 167, 69, 0.2);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.personal-stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 12px rgba(40, 167, 69, 0.3);
}
.personal-stat-card h4 {
font-size: 14px;
opacity: 0.9;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 500;
}
.personal-stat-card .value {
font-size: 42px;
font-weight: bold;
margin-bottom: 5px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.personal-stat-card .label {
font-size: 12px;
opacity: 0.8;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 30px;
}
.chart-container {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.chart-container h4 {
color: #333;
margin-bottom: 20px;
font-size: 18px;
font-weight: 600;
}
.activity-timeline {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-top: 30px;
}
.activity-timeline h4 {
color: #333;
margin-bottom: 20px;
font-size: 18px;
font-weight: 600;
}
.breakdown-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-top: 20px;
}
.breakdown-item {
background: #f8f9fa;
border-radius: 8px;
padding: 15px;
text-align: center;
}
.breakdown-item .value {
font-size: 24px;
font-weight: bold;
color: #28a745;
margin-bottom: 5px;
}
.breakdown-item .label {
font-size: 12px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.achievement-badge {
display: inline-block;
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
color: #333;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
margin-top: 15px;
box-shadow: 0 2px 4px rgba(255, 215, 0, 0.3);
}
.loading-personal-stats {
text-align: center;
padding: 60px;
color: #666;
font-size: 16px;
}
.no-personal-data {
text-align: center;
padding: 60px;
color: #999;
font-size: 16px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.no-personal-data p {
margin-bottom: 20px;
}
.get-started-btn {
background-color: #28a745;
color: white;
border: none;
padding: 12px 24px;
border-radius: 24px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.get-started-btn:hover {
background-color: #218838;
}
@media (max-width: 768px) {
.personal-stats-grid {
grid-template-columns: 1fr;
}
.personal-stat-card .value {
font-size: 32px;
}
.charts-grid {
grid-template-columns: 1fr;
}
.breakdown-stats {
grid-template-columns: 1fr 1fr;
}
}
+289
View File
@@ -0,0 +1,289 @@
import React, { useState, useEffect, useContext } from "react";
import axios from "axios";
import { AuthContext } from "../context/AuthContext";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import "./PersonalStats.css";
const PersonalStats = ({ timeframe }) => {
const { user } = useContext(AuthContext);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchPersonalStats = async () => {
if (!user) return;
setLoading(true);
setError(null);
try {
const token = localStorage.getItem("token");
const config = {
headers: {
"x-auth-token": token,
},
};
const res = await axios.get(
`/api/analytics/user/${user._id}?timeframe=${timeframe}`,
config
);
setStats(res.data);
} catch (err) {
console.error("Error fetching personal stats:", err);
setError(err.response?.data?.msg || "Failed to load personal statistics");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPersonalStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeframe, user]);
if (loading) {
return (
<div className="text-center">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger" role="alert">
{error}
</div>
);
}
if (!stats) return null;
const chartData = [
{ name: "Streets", value: stats.stats.streetsAdopted },
{ name: "Tasks", value: stats.stats.tasksCompleted },
{ name: "Posts", value: stats.stats.postsCreated },
{ name: "Events", value: stats.stats.eventsParticipated },
{ name: "Badges", value: stats.stats.badgesEarned },
];
return (
<div className="personal-stats">
<div className="row mb-4">
<div className="col-md-12">
<div className="user-header">
<h3>{stats.user.name}</h3>
<div className="user-badges">
{stats.user.isPremium && (
<span className="badge bg-warning text-dark">Premium</span>
)}
<span className="badge bg-primary">{stats.user.points} Points</span>
</div>
</div>
</div>
</div>
<div className="row mb-4">
<div className="col-md-2">
<div className="stat-box">
<h4>{stats.stats.streetsAdopted}</h4>
<p>Streets</p>
</div>
</div>
<div className="col-md-2">
<div className="stat-box">
<h4>{stats.stats.tasksCompleted}</h4>
<p>Tasks</p>
</div>
</div>
<div className="col-md-2">
<div className="stat-box">
<h4>{stats.stats.postsCreated}</h4>
<p>Posts</p>
</div>
</div>
<div className="col-md-2">
<div className="stat-box">
<h4>{stats.stats.eventsParticipated}</h4>
<p>Events</p>
</div>
</div>
<div className="col-md-2">
<div className="stat-box">
<h4>{stats.stats.badgesEarned}</h4>
<p>Badges</p>
</div>
</div>
<div className="col-md-2">
<div className="stat-box">
<h4>{stats.stats.totalLikesReceived}</h4>
<p>Likes</p>
</div>
</div>
</div>
<div className="row mb-4">
<div className="col-md-6">
<div className="card">
<div className="card-header">
<h5>Activity Overview</h5>
</div>
<div className="card-body">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="value" fill="#0d6efd" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card">
<div className="card-header">
<h5>Points Summary</h5>
</div>
<div className="card-body">
<div className="points-summary">
<div className="points-row">
<span className="points-label">Points Earned:</span>
<span className="points-value text-success">
+{stats.stats.pointsEarned}
</span>
</div>
<div className="points-row">
<span className="points-label">Points Spent:</span>
<span className="points-value text-danger">
-{stats.stats.pointsSpent}
</span>
</div>
<hr />
<div className="points-row">
<span className="points-label">
<strong>Current Balance:</strong>
</span>
<span className="points-value">
<strong>{stats.user.points}</strong>
</span>
</div>
</div>
<div className="mt-4">
<h6>Engagement Metrics</h6>
<div className="engagement-metrics">
<div className="metric">
<span className="metric-icon">
<i className="fas fa-heart"></i>
</span>
<span className="metric-value">
{stats.stats.totalLikesReceived}
</span>
<span className="metric-label">Likes Received</span>
</div>
<div className="metric">
<span className="metric-icon">
<i className="fas fa-comment"></i>
</span>
<span className="metric-value">
{stats.stats.totalCommentsReceived}
</span>
<span className="metric-label">Comments Received</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{stats.recentActivity && (
<div className="row">
<div className="col-md-12">
<div className="card">
<div className="card-header">
<h5>Recent Activity</h5>
</div>
<div className="card-body">
<div className="row">
<div className="col-md-4">
<h6>Recent Tasks</h6>
{stats.recentActivity.tasks.length > 0 ? (
<ul className="list-unstyled">
{stats.recentActivity.tasks.map((task) => (
<li key={task._id} className="mb-2">
<small className="text-muted">
{new Date(task.completedAt || task.createdAt).toLocaleDateString()}
</small>
<div>{task.description}</div>
</li>
))}
</ul>
) : (
<p className="text-muted">No recent tasks</p>
)}
</div>
<div className="col-md-4">
<h6>Recent Posts</h6>
{stats.recentActivity.posts.length > 0 ? (
<ul className="list-unstyled">
{stats.recentActivity.posts.map((post) => (
<li key={post._id} className="mb-2">
<small className="text-muted">
{new Date(post.createdAt).toLocaleDateString()}
</small>
<div>{post.content.substring(0, 50)}...</div>
</li>
))}
</ul>
) : (
<p className="text-muted">No recent posts</p>
)}
</div>
<div className="col-md-4">
<h6>Recent Events</h6>
{stats.recentActivity.events.length > 0 ? (
<ul className="list-unstyled">
{stats.recentActivity.events.map((event) => (
<li key={event._id} className="mb-2">
<small className="text-muted">
{new Date(event.date).toLocaleDateString()}
</small>
<div>{event.title}</div>
</li>
))}
</ul>
) : (
<p className="text-muted">No recent events</p>
)}
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default PersonalStats;
+137
View File
@@ -0,0 +1,137 @@
import React from "react";
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts";
const StreetStatsChart = ({ data }) => {
if (!data) return null;
const adoptionData = [
{ name: "Adopted", value: data.adoption.adoptedStreets, color: "#198754" },
{ name: "Available", value: data.adoption.availableStreets, color: "#6c757d" },
];
const taskData = [
{ name: "Completed", value: data.tasks.completedTasks, color: "#0d6efd" },
{ name: "Pending", value: data.tasks.pendingTasks, color: "#ffc107" },
{ name: "In Progress", value: data.tasks.inProgressTasks, color: "#fd7e14" },
];
const RADIAN = Math.PI / 180;
const renderCustomizedLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
percent,
}) => {
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
return (
<text
x={x}
y={y}
fill="white"
textAnchor={x > cx ? "start" : "end"}
dominantBaseline="central"
fontWeight="bold"
>
{`${(percent * 100).toFixed(0)}%`}
</text>
);
};
return (
<div>
<div className="row mb-4">
<div className="col-md-4">
<div className="stat-highlight">
<h3>{data.adoption.adoptionRate}%</h3>
<p className="text-muted">Adoption Rate</p>
</div>
</div>
<div className="col-md-4">
<div className="stat-highlight">
<h3>{data.tasks.completionRate}%</h3>
<p className="text-muted">Task Completion Rate</p>
</div>
</div>
<div className="col-md-4">
<div className="stat-highlight">
<h3>{data.adoption.totalStreets}</h3>
<p className="text-muted">Total Streets</p>
</div>
</div>
</div>
<div className="row">
<div className="col-md-6">
<h6 className="text-center mb-3">Street Adoption</h6>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={adoptionData}
cx="50%"
cy="50%"
labelLine={false}
label={renderCustomizedLabel}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{adoptionData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
<div className="col-md-6">
<h6 className="text-center mb-3">Task Status</h6>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={taskData}
cx="50%"
cy="50%"
labelLine={false}
label={renderCustomizedLabel}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{taskData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
{data.topStreets && data.topStreets.length > 0 && (
<div className="mt-4">
<h6>Top Streets by Task Completion</h6>
<div className="list-group">
{data.topStreets.slice(0, 5).map((street, index) => (
<div key={street.streetId} className="list-group-item d-flex justify-content-between align-items-center">
<span>
<strong>#{index + 1}</strong> {street.streetName}
</span>
<span className="badge bg-primary rounded-pill">{street.count} tasks</span>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default StreetStatsChart;
@@ -0,0 +1,71 @@
import React, { useState, useEffect, useContext } from \"react\";
import { useParams, Link } from \"react-router-dom\";
import axios from \"axios\";
const { AuthContext } = require(\"../../context/AuthContext\");
const ProfileView = () => {
const { userId } = useParams();
const { auth } = useContext(AuthContext);
const [profile, setProfile] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchProfile = async () => {
try {
const res = await axios.get((/api/profile/${userId}`);
setProfile(res.data);
} catch (err) {
setError(err.response?.ager = == auth.user._id;
return (
<div className=\"container mt-5\">
<div className=\"row\">
<div className=\"col-md-4 text-center\">
<img
src={`npame"} id: auth.user._id });
setProfile(res.data);
} catch (err) {
setError(err.response?.data?.msg || \"Error fetching profile\");
}
setLoading(false);
};
fetchProfile();
}, [userId]);
if (loading) {
return <div className=\"container\"><p>Loading profile...</p></div>;
}
if (error) {
return <div className=\"container\"><div className=\"alert alert-danger\">{error}</div></div>;
}
if (!profile) {
return <div className=\"container\"><p>Profile not found.</p></div>;
}
const { name, avatar, bio, location, website, social, preferences } = profile;
const isOwnProfile = auth.isAuthenticated && auth.user._id === userId;
return (
<div className=\"container mt-5\">
<div className=\"row\">
<div className=\"col-md-4 text-center\">
<img
src={avatar || \"/logo512.png\"}
alt={`${name}\'s avatar`}
className=\"img-fluid rounded-circle mb-3\"
style={{ width: \"150px\", height: \"150px\" }}
/>
<h3>{name}</h3>
{location && <p className=\"text-muted\">{location}</p>}
{isOwnProfile && (
<Link to=\"/profile/edit\" className=\"btn btn-primary mb-3\">Edit Profile</Link>
)}
</div>
<div className=\"col-md-8\">
<div className=\"card\">
<div className=\"card-body\">