diff --git a/backend/__tests__/jest.preSetup.js b/backend/__tests__/jest.preSetup.js index 85d08c4..9e2add8 100644 --- a/backend/__tests__/jest.preSetup.js +++ b/backend/__tests__/jest.preSetup.js @@ -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(() => ({ diff --git a/backend/__tests__/routes/analytics.test.js b/backend/__tests__/routes/analytics.test.js new file mode 100644 index 0000000..fe96814 --- /dev/null +++ b/backend/__tests__/routes/analytics.test.js @@ -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); + }); + }); +}); diff --git a/backend/__tests__/routes/leaderboard.test.js b/backend/__tests__/routes/leaderboard.test.js new file mode 100644 index 0000000..966fcc0 --- /dev/null +++ b/backend/__tests__/routes/leaderboard.test.js @@ -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); + }); + }); +}); diff --git a/backend/middleware/validators/profileValidator.js b/backend/middleware/validators/profileValidator.js new file mode 100644 index 0000000..790f228 --- /dev/null +++ b/backend/middleware/validators/profileValidator.js @@ -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 }; + diff --git a/backend/models/User.js b/backend/models/User.js index e34da91..76a39e0 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -1,25 +1,19 @@ const bcrypt = require("bcryptjs"); const couchdbService = require("../services/couchdbService"); -const { - ValidationError, - NotFoundError, - DatabaseError, - DuplicateError, +const { + ValidationError, 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; diff --git a/backend/routes/analytics.js b/backend/routes/analytics.js new file mode 100644 index 0000000..32d89d0 --- /dev/null +++ b/backend/routes/analytics.js @@ -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; diff --git a/backend/routes/badges.js b/backend/routes/badges.js index 5f6cc50..da34f17 100644 --- a/backend/routes/badges.js +++ b/backend/routes/badges.js @@ -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; diff --git a/backend/routes/leaderboard.js b/backend/routes/leaderboard.js new file mode 100644 index 0000000..90c18d4 --- /dev/null +++ b/backend/routes/leaderboard.js @@ -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; diff --git a/backend/routes/profile.js b/backend/routes/profile.js new file mode 100644 index 0000000..0db0330 --- /dev/null +++ b/backend/routes/profile.js @@ -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; + diff --git a/backend/routes/users.js b/backend/routes/users.js index 0aada3d..2037871 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -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; diff --git a/backend/server.js b/backend/server.js index 27b428c..e4d8fcb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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"); diff --git a/backend/services/gamificationService.js b/backend/services/gamificationService.js index e592364..2f99f15 100644 --- a/backend/services/gamificationService.js +++ b/backend/services/gamificationService.js @@ -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} 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} 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} 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} 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} 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} 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, }; \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ccf1fc0..514bad5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index fe099fa..e656947 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" }, diff --git a/frontend/src/App.js b/frontend/src/App.js index f163536..c175758 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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() { } /> } /> } /> + } /> } /> + } /> } /> diff --git a/frontend/src/__tests__/Leaderboard.test.js b/frontend/src/__tests__/Leaderboard.test.js new file mode 100644 index 0000000..6fd52a5 --- /dev/null +++ b/frontend/src/__tests__/Leaderboard.test.js @@ -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( + + + + + + ); + }; + + 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(); + }); + }); + }); +}); diff --git a/frontend/src/components/ActivityChart.js b/frontend/src/components/ActivityChart.js new file mode 100644 index 0000000..c34a2b9 --- /dev/null +++ b/frontend/src/components/ActivityChart.js @@ -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 ( +
+
+
Activity Trends
+ + + + + + + + + + + + + +
+ +
+
Activity Comparison
+ + + + + + + + + + + + + +
+
+ ); +}; + +export default ActivityChart; diff --git a/frontend/src/components/Analytics.css b/frontend/src/components/Analytics.css new file mode 100644 index 0000000..5df4735 --- /dev/null +++ b/frontend/src/components/Analytics.css @@ -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; + } +} diff --git a/frontend/src/components/Analytics.js b/frontend/src/components/Analytics.js new file mode 100644 index 0000000..2b571a6 --- /dev/null +++ b/frontend/src/components/Analytics.js @@ -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 ( +
+
+
+ Loading... +
+

Loading analytics...

+
+
+ ); + } + + if (error) { + return ( +
+
+ {error} +
+
+ ); + } + + return ( +
+
+

Analytics Dashboard

+
+
+ + +
+ {activeTab === "activity" && ( +
+ + +
+ )} +
+
+ +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ + {activeTab === "overview" && overview && ( +
+
+
+
+
+ +
+
+

{overview.totalUsers}

+

Total Users

+
+
+
+ +
+
+
+ +
+
+

{overview.adoptedStreets}

+

Streets Adopted

+ + {overview.availableStreets} available + +
+
+
+ +
+
+
+ +
+
+

{overview.completedTasks}

+

Tasks Completed

+ + {overview.pendingTasks} pending + +
+
+
+ +
+
+
+ +
+
+

{overview.activeEvents}

+

Active Events

+ + {overview.completedEvents} completed + +
+
+
+
+ +
+
+
+
+ +
+
+

{overview.totalPosts}

+

Total Posts

+
+
+
+ +
+
+
+ +
+
+

{overview.totalPoints.toLocaleString()}

+

Total Points

+
+
+
+ +
+
+
+ +
+
+

{overview.averagePointsPerUser}

+

Avg Points/User

+
+
+
+
+ +
+
+
+
+
Street Statistics
+
+
+ {streetStats && } +
+
+
+ +
+
+
+
Top Contributors
+
+
+ +
+
+
+
+
+ )} + + {activeTab === "activity" && activity && ( +
+
+
+
Activity Over Time
+
+
+ +
+
+ +
+
+
+

{activity.summary.totalTasks}

+

Total Tasks

+
+
+
+
+

{activity.summary.totalPosts}

+

Total Posts

+
+
+
+
+

{activity.summary.totalEvents}

+

Total Events

+
+
+
+
+

{activity.summary.totalStreetsAdopted}

+

Streets Adopted

+
+
+
+
+ )} + + {activeTab === "personal" && ( +
+ +
+ )} +
+ ); +}; + +export default Analytics; diff --git a/frontend/src/components/BadgeCollection.js b/frontend/src/components/BadgeCollection.js new file mode 100644 index 0000000..c6a7a03 --- /dev/null +++ b/frontend/src/components/BadgeCollection.js @@ -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 ( +
+
+ Loading... +
+

Loading badges...

+
+ ); + } + + if (error) { + return ( +
+

Error Loading Badges

+

{error}

+
+ ); + } + + if (!auth.isAuthenticated) { + return ( +
+

Not Logged In

+

Please log in to view badges.

+
+ ); + } + + return ( +
+
+

Badge Collection

+
+ + {earnedCount} / {totalCount} Earned + +
+
+ + {/* Filter and Sort Controls */} +
+
+
+
+ + +
+
+ + +
+
+
+
+ + {/* Badge Grid */} + {sortedBadges.length === 0 ? ( +
+

No badges found with the selected filter.

+
+ ) : ( +
+ {sortedBadges.map((badge) => ( +
+ +
+ ))} +
+ )} +
+ ); +}; + +export default BadgeCollection; diff --git a/frontend/src/components/BadgeDisplay.js b/frontend/src/components/BadgeDisplay.js new file mode 100644 index 0000000..d1efd1a --- /dev/null +++ b/frontend/src/components/BadgeDisplay.js @@ -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 ( +
+
{badge.icon || "🏆"}
+
{badge.name}
+

{badge.description}

+ + {badge.rarity} + + {isEarned && ( +
+ ✓ Earned +
+ )} +
+ ); +}; + +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; diff --git a/frontend/src/components/BadgeProgress.js b/frontend/src/components/BadgeProgress.js new file mode 100644 index 0000000..8717504 --- /dev/null +++ b/frontend/src/components/BadgeProgress.js @@ -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 ( +
+

+ Complete tasks and participate in events to earn badges! +

+
+ ); + } + + return ( +
+
Badges In Progress
+ {inProgressBadges.map((badge) => { + const percentage = Math.round((badge.progress / badge.threshold) * 100); + return ( +
+
+
+ + {badge.icon || "🏆"} + +
+ {badge.name} +
+ {badge.description} +
+
+ + {badge.progress} / {badge.threshold} + +
+
+
= 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}% +
+
+
+ ); + })} +
+ ); +}; + +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; diff --git a/frontend/src/components/ContributorsList.css b/frontend/src/components/ContributorsList.css new file mode 100644 index 0000000..cfe9a99 --- /dev/null +++ b/frontend/src/components/ContributorsList.css @@ -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; + } +} diff --git a/frontend/src/components/ContributorsList.js b/frontend/src/components/ContributorsList.js new file mode 100644 index 0000000..6bd42c5 --- /dev/null +++ b/frontend/src/components/ContributorsList.js @@ -0,0 +1,50 @@ +import React from "react"; +import "./ContributorsList.css"; + +const ContributorsList = ({ contributors }) => { + if (!contributors || contributors.length === 0) { + return

No contributors data available.

; + } + + return ( +
+ {contributors.map((contributor, index) => ( +
+
#{index + 1}
+
+ {contributor.profilePicture ? ( + {contributor.name} + ) : ( +
+ {contributor.name.charAt(0).toUpperCase()} +
+ )} +
+
+
+ {contributor.name} + {contributor.isPremium && ( + Premium + )} +
+
+ + {contributor.stats.points} pts | {contributor.stats.tasksCompleted} tasks |{" "} + {contributor.stats.streetsAdopted} streets + +
+
+
+ {contributor.score} +
+
+ ))} +
+ ); +}; + +export default ContributorsList; diff --git a/frontend/src/components/Leaderboard.js b/frontend/src/components/Leaderboard.js new file mode 100644 index 0000000..4c49c38 --- /dev/null +++ b/frontend/src/components/Leaderboard.js @@ -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 ( +
+
+ Loading... +
+

Loading leaderboard...

+
+ ); + } + + if (error) { + return ( +
+

Error Loading Leaderboard

+

{error}

+
+ +
+ ); + } + + return ( +
+

Leaderboard

+ + {/* Leaderboard Stats */} + {stats && ( +
+
+
+ Total Users: {stats.totalUsers} +
+
+ Total Points: {stats.totalPoints.toLocaleString()} +
+
+ Avg Points: {Math.round(stats.averagePoints)} +
+
+ Top Score: {stats.maxPoints.toLocaleString()} +
+
+
+ )} + + {/* Tab Navigation */} +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ + {/* Current User Info */} + {auth.user && activeTab !== "friends" && ( +
+ Your Points:{" "} + {auth.user.points || 0} +
+ )} + + {/* Leaderboard List */} + {leaderboard.length === 0 ? ( +
+ {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!"} +
+ ) : ( + <> +
+ {leaderboard.map((entry, index) => ( + + ))} +
+ + {/* Pagination */} +
+ + + Page {page} {hasMore && "- More available"} + + +
+ + )} +
+ ); +}; + +export default Leaderboard; diff --git a/frontend/src/components/LeaderboardCard.js b/frontend/src/components/LeaderboardCard.js new file mode 100644 index 0000000..63adee9 --- /dev/null +++ b/frontend/src/components/LeaderboardCard.js @@ -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 ( +
+
+
+
+ {/* Rank */} +
+

+ + {getRankEmoji()} #{rank} + +

+
+ + {/* User Info */} +
+
+ {entry.username || "Unknown User"} + {isCurrentUser && ( + You + )} +
+
+ {entry.email &&
{entry.email}
} +
+
+ + {/* Points */} +
+
Points
+

+ {entry.points.toLocaleString()} +

+
+ + {/* Stats */} +
+
Streets
+
{entry.streetsAdopted || 0}
+
Tasks
+
{entry.tasksCompleted || 0}
+
+ + {/* Badges */} +
+
Badges
+
+ {entry.badges && entry.badges.length > 0 ? ( + entry.badges.slice(0, 5).map((badge, index) => ( + + {badge.icon || "🏆"} + + )) + ) : ( + None + )} + {entry.badges && entry.badges.length > 5 && ( + + +{entry.badges.length - 5} + + )} +
+
+
+
+
+
+ ); +}; + +export default LeaderboardCard; diff --git a/frontend/src/components/Navbar.js b/frontend/src/components/Navbar.js index 763cd4b..465b6b4 100644 --- a/frontend/src/components/Navbar.js +++ b/frontend/src/components/Navbar.js @@ -22,6 +22,12 @@ const Navbar = () => {
  • Rewards
  • +
  • + Leaderboard +
  • +
  • + Analytics +
  • Profile
  • diff --git a/frontend/src/components/PersonalStats.css b/frontend/src/components/PersonalStats.css new file mode 100644 index 0000000..9ceabb9 --- /dev/null +++ b/frontend/src/components/PersonalStats.css @@ -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; + } +} diff --git a/frontend/src/components/PersonalStats.js b/frontend/src/components/PersonalStats.js new file mode 100644 index 0000000..50c994a --- /dev/null +++ b/frontend/src/components/PersonalStats.js @@ -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 ( +
    +
    + Loading... +
    +
    + ); + } + + if (error) { + return ( +
    + {error} +
    + ); + } + + 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 ( +
    +
    +
    +
    +

    {stats.user.name}

    +
    + {stats.user.isPremium && ( + Premium + )} + {stats.user.points} Points +
    +
    +
    +
    + +
    +
    +
    +

    {stats.stats.streetsAdopted}

    +

    Streets

    +
    +
    +
    +
    +

    {stats.stats.tasksCompleted}

    +

    Tasks

    +
    +
    +
    +
    +

    {stats.stats.postsCreated}

    +

    Posts

    +
    +
    +
    +
    +

    {stats.stats.eventsParticipated}

    +

    Events

    +
    +
    +
    +
    +

    {stats.stats.badgesEarned}

    +

    Badges

    +
    +
    +
    +
    +

    {stats.stats.totalLikesReceived}

    +

    Likes

    +
    +
    +
    + +
    +
    +
    +
    +
    Activity Overview
    +
    +
    + + + + + + + + + + +
    +
    +
    + +
    +
    +
    +
    Points Summary
    +
    +
    +
    +
    + Points Earned: + + +{stats.stats.pointsEarned} + +
    +
    + Points Spent: + + -{stats.stats.pointsSpent} + +
    +
    +
    + + Current Balance: + + + {stats.user.points} + +
    +
    + +
    +
    Engagement Metrics
    +
    +
    + + + + + {stats.stats.totalLikesReceived} + + Likes Received +
    +
    + + + + + {stats.stats.totalCommentsReceived} + + Comments Received +
    +
    +
    +
    +
    +
    +
    + + {stats.recentActivity && ( +
    +
    +
    +
    +
    Recent Activity
    +
    +
    +
    +
    +
    Recent Tasks
    + {stats.recentActivity.tasks.length > 0 ? ( +
      + {stats.recentActivity.tasks.map((task) => ( +
    • + + {new Date(task.completedAt || task.createdAt).toLocaleDateString()} + +
      {task.description}
      +
    • + ))} +
    + ) : ( +

    No recent tasks

    + )} +
    + +
    +
    Recent Posts
    + {stats.recentActivity.posts.length > 0 ? ( +
      + {stats.recentActivity.posts.map((post) => ( +
    • + + {new Date(post.createdAt).toLocaleDateString()} + +
      {post.content.substring(0, 50)}...
      +
    • + ))} +
    + ) : ( +

    No recent posts

    + )} +
    + +
    +
    Recent Events
    + {stats.recentActivity.events.length > 0 ? ( +
      + {stats.recentActivity.events.map((event) => ( +
    • + + {new Date(event.date).toLocaleDateString()} + +
      {event.title}
      +
    • + ))} +
    + ) : ( +

    No recent events

    + )} +
    +
    +
    +
    +
    +
    + )} +
    + ); +}; + +export default PersonalStats; diff --git a/frontend/src/components/StreetStatsChart.js b/frontend/src/components/StreetStatsChart.js new file mode 100644 index 0000000..034a17f --- /dev/null +++ b/frontend/src/components/StreetStatsChart.js @@ -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 ( + cx ? "start" : "end"} + dominantBaseline="central" + fontWeight="bold" + > + {`${(percent * 100).toFixed(0)}%`} + + ); + }; + + return ( +
    +
    +
    +
    +

    {data.adoption.adoptionRate}%

    +

    Adoption Rate

    +
    +
    +
    +
    +

    {data.tasks.completionRate}%

    +

    Task Completion Rate

    +
    +
    +
    +
    +

    {data.adoption.totalStreets}

    +

    Total Streets

    +
    +
    +
    + +
    +
    +
    Street Adoption
    + + + + {adoptionData.map((entry, index) => ( + + ))} + + + + + +
    + +
    +
    Task Status
    + + + + {taskData.map((entry, index) => ( + + ))} + + + + + +
    +
    + + {data.topStreets && data.topStreets.length > 0 && ( +
    +
    Top Streets by Task Completion
    +
    + {data.topStreets.slice(0, 5).map((street, index) => ( +
    + + #{index + 1} {street.streetName} + + {street.count} tasks +
    + ))} +
    +
    + )} +
    + ); +}; + +export default StreetStatsChart; diff --git a/frontend/src/components/profile/ProfileEdit.js b/frontend/src/components/profile/ProfileEdit.js new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/profile/ProfileView.js b/frontend/src/components/profile/ProfileView.js new file mode 100644 index 0000000..96a8f88 --- /dev/null +++ b/frontend/src/components/profile/ProfileView.js @@ -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 ( +
    +
    +
    +

    Loading profile...

    ; + } + + if (error) { + return
    {error}
    ; + } + + if (!profile) { + return

    Profile not found.

    ; + } + + const { name, avatar, bio, location, website, social, preferences } = profile; + const isOwnProfile = auth.isAuthenticated && auth.user._id === userId; + + return ( +
    +
    +
    + {`${name}\'s +

    {name}

    + {location &&

    {location}

    } + {isOwnProfile && ( + Edit Profile + )} +
    +
    +
    +
    + \ No newline at end of file diff --git a/frontend/src/components/profile/__tests__/ProfileEdit.test.js b/frontend/src/components/profile/__tests__/ProfileEdit.test.js new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/profile/__tests__/ProfileView.test.js b/frontend/src/components/profile/__tests__/ProfileView.test.js new file mode 100644 index 0000000..e69de29