feat: implement comprehensive gamification, analytics, and leaderboard system

This commit adds a complete gamification system with analytics dashboards,
leaderboards, and enhanced badge tracking functionality.

Backend Features:
- Analytics API with overview, user stats, activity trends, top contributors,
  and street statistics endpoints
- Leaderboard API supporting global, weekly, monthly, and friends views
- Profile API for viewing and managing user profiles
- Enhanced gamification service with badge progress tracking and user stats
- Comprehensive test coverage for analytics and leaderboard endpoints
- Profile validation middleware for secure profile updates

Frontend Features:
- Analytics dashboard with multiple tabs (Overview, Activity, Personal Stats)
- Interactive charts for activity trends and street statistics
- Leaderboard component with pagination and timeframe filtering
- Badge collection display with progress tracking
- Personal stats component showing user achievements
- Contributors list for top performing users
- Profile management components (View/Edit)
- Toast notifications integrated throughout
- Comprehensive test coverage for Leaderboard component

Enhancements:
- User model enhanced with stats tracking and badge management
- Fixed express.Router() capitalization bug in users route
- Badge service improvements for better criteria matching
- Removed unused imports in Profile component

This feature enables users to track their contributions, view community
analytics, compete on leaderboards, and earn badges for achievements.

🤖 Generated with OpenCode

Co-Authored-By: AI Assistant <noreply@opencode.ai>
This commit is contained in:
William Valentin
2025-11-03 13:53:48 -08:00
parent ae77e30ffb
commit 3e4c730860
34 changed files with 5533 additions and 190 deletions
+4
View File
@@ -15,7 +15,9 @@ import SocialFeed from "./components/SocialFeed";
import Profile from "./components/Profile";
import Events from "./components/Events";
import Rewards from "./components/Rewards";
import Leaderboard from "./components/Leaderboard";
import Premium from "./components/Premium";
import Analytics from "./components/Analytics";
import Navbar from "./components/Navbar";
import PrivateRoute from "./components/PrivateRoute";
@@ -36,7 +38,9 @@ function App() {
<Route path="/profile" element={<PrivateRoute><Profile /></PrivateRoute>} />
<Route path="/events" element={<PrivateRoute><Events /></PrivateRoute>} />
<Route path="/rewards" element={<PrivateRoute><Rewards /></PrivateRoute>} />
<Route path="/leaderboard" element={<PrivateRoute><Leaderboard /></PrivateRoute>} />
<Route path="/premium" element={<PrivateRoute><Premium /></PrivateRoute>} />
<Route path="/analytics" element={<PrivateRoute><Analytics /></PrivateRoute>} />
<Route path="/" element={<Navigate to="/map" replace />} />
</Routes>
</div>
+480
View File
@@ -0,0 +1,480 @@
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import axios from "axios";
import { toast } from "react-toastify";
import Leaderboard from "../components/Leaderboard";
import { AuthContext } from "../context/AuthContext";
// Mock axios
jest.mock("axios");
// Mock react-toastify
jest.mock("react-toastify", () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
warning: jest.fn(),
},
}));
// Mock leaderboard data
const mockLeaderboardData = [
{
userId: "user1",
username: "TopUser",
email: "topuser@example.com",
points: 1000,
streetsAdopted: 5,
tasksCompleted: 20,
badges: [
{ name: "Beginner", icon: "🏅" },
{ name: "Intermediate", icon: "🏆" },
],
},
{
userId: "user2",
username: "SecondUser",
email: "second@example.com",
points: 800,
streetsAdopted: 4,
tasksCompleted: 15,
badges: [{ name: "Beginner", icon: "🏅" }],
},
{
userId: "user3",
username: "ThirdUser",
email: "third@example.com",
points: 600,
streetsAdopted: 3,
tasksCompleted: 10,
badges: [],
},
];
const mockStats = {
totalUsers: 100,
totalPoints: 50000,
averagePoints: 500,
maxPoints: 1000,
minPoints: 0,
};
describe("Leaderboard Component", () => {
const mockAuthContext = {
auth: {
isAuthenticated: true,
user: {
_id: "user1",
username: "TopUser",
points: 1000,
},
},
};
const renderLeaderboard = (authContext = mockAuthContext) => {
return render(
<BrowserRouter>
<AuthContext.Provider value={authContext}>
<Leaderboard />
</AuthContext.Provider>
</BrowserRouter>
);
};
beforeEach(() => {
jest.clearAllMocks();
localStorage.setItem("token", "mock-token");
});
afterEach(() => {
localStorage.clear();
});
describe("Initial Loading", () => {
it("should display loading spinner on initial load", () => {
axios.get.mockImplementation(() => new Promise(() => {})); // Never resolves
renderLeaderboard();
expect(screen.getByText(/loading leaderboard/i)).toBeInTheDocument();
expect(screen.getByRole("status")).toBeInTheDocument();
});
it("should load global leaderboard by default", async () => {
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/global")) {
return Promise.resolve({ data: mockLeaderboardData });
}
if (url.includes("/api/leaderboard/stats")) {
return Promise.resolve({ data: mockStats });
}
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("/api/leaderboard/global"),
expect.anything()
);
});
it("should load leaderboard stats", async () => {
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/global")) {
return Promise.resolve({ data: mockLeaderboardData });
}
if (url.includes("/api/leaderboard/stats")) {
return Promise.resolve({ data: mockStats });
}
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText(/total users:/i)).toBeInTheDocument();
});
expect(screen.getByText(/100/)).toBeInTheDocument();
expect(screen.getByText(/50,000/)).toBeInTheDocument();
});
});
describe("Tab Navigation", () => {
beforeEach(() => {
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: mockLeaderboardData });
}
return Promise.resolve({ data: mockStats });
});
});
it("should switch to weekly leaderboard when clicking weekly tab", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
const weeklyTab = screen.getByRole("button", { name: /this week/i });
fireEvent.click(weeklyTab);
await waitFor(() => {
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("/api/leaderboard/weekly"),
expect.anything()
);
});
});
it("should switch to monthly leaderboard when clicking monthly tab", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
const monthlyTab = screen.getByRole("button", { name: /this month/i });
fireEvent.click(monthlyTab);
await waitFor(() => {
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("/api/leaderboard/monthly"),
expect.anything()
);
});
});
it("should switch to friends leaderboard when authenticated", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
const friendsTab = screen.getByRole("button", { name: /friends/i });
fireEvent.click(friendsTab);
await waitFor(() => {
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("/api/leaderboard/friends"),
expect.objectContaining({
headers: { "x-auth-token": "mock-token" },
})
);
});
});
it("should show warning when trying to access friends tab without authentication", async () => {
const unauthContext = {
auth: {
isAuthenticated: false,
user: null,
},
};
axios.get.mockResolvedValue({ data: mockLeaderboardData });
renderLeaderboard(unauthContext);
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
const friendsTab = screen.getByRole("button", { name: /friends/i });
fireEvent.click(friendsTab);
expect(toast.warning).toHaveBeenCalledWith(
"Please login to view friends leaderboard"
);
});
});
describe("User Display", () => {
beforeEach(() => {
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: mockLeaderboardData });
}
return Promise.resolve({ data: mockStats });
});
});
it("should display all users in leaderboard", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
expect(screen.getByText("SecondUser")).toBeInTheDocument();
expect(screen.getByText("ThirdUser")).toBeInTheDocument();
});
});
it("should highlight current user", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
expect(screen.getByText("You")).toBeInTheDocument();
});
it("should display user points", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("1,000")).toBeInTheDocument();
});
});
it("should display user statistics", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
// Check for streets and tasks counts
const statsElements = screen.getAllByText(/5|20/);
expect(statsElements.length).toBeGreaterThan(0);
});
it("should display current user points in alert", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText(/your points:/i)).toBeInTheDocument();
});
expect(screen.getByText("1000")).toBeInTheDocument();
});
});
describe("Pagination", () => {
beforeEach(() => {
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: mockLeaderboardData });
}
return Promise.resolve({ data: mockStats });
});
});
it("should disable previous button on first page", async () => {
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
const previousButton = screen.getByRole("button", { name: /previous/i });
expect(previousButton).toBeDisabled();
});
it("should enable next button when there are more results", async () => {
// Mock 50 results to trigger hasMore
const largeDataset = Array.from({ length: 50 }, (_, i) => ({
userId: `user${i}`,
username: `User${i}`,
points: 1000 - i * 10,
streetsAdopted: 1,
tasksCompleted: 1,
badges: [],
}));
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: largeDataset });
}
return Promise.resolve({ data: mockStats });
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("User0")).toBeInTheDocument();
});
const nextButton = screen.getByRole("button", { name: /next/i });
expect(nextButton).not.toBeDisabled();
});
it("should load next page when clicking next button", async () => {
const largeDataset = Array.from({ length: 50 }, (_, i) => ({
userId: `user${i}`,
username: `User${i}`,
points: 1000 - i * 10,
streetsAdopted: 1,
tasksCompleted: 1,
badges: [],
}));
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: largeDataset });
}
return Promise.resolve({ data: mockStats });
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("User0")).toBeInTheDocument();
});
const nextButton = screen.getByRole("button", { name: /next/i });
fireEvent.click(nextButton);
await waitFor(() => {
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("offset=50"),
expect.anything()
);
});
});
});
describe("Error Handling", () => {
it("should display error message when API fails", async () => {
axios.get.mockRejectedValue({
response: { data: { msg: "Server error" } },
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText(/error loading leaderboard/i)).toBeInTheDocument();
});
expect(toast.error).toHaveBeenCalledWith("Server error");
});
it("should show retry button on error", async () => {
axios.get.mockRejectedValue({
response: { data: { msg: "Server error" } },
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
});
it("should retry loading when clicking retry button", async () => {
axios.get
.mockRejectedValueOnce({
response: { data: { msg: "Server error" } },
})
.mockImplementation((url) => {
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: mockLeaderboardData });
}
return Promise.resolve({ data: mockStats });
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
const retryButton = screen.getByRole("button", { name: /retry/i });
fireEvent.click(retryButton);
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
});
});
describe("Empty State", () => {
it("should display message when leaderboard is empty", async () => {
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: [] });
}
return Promise.resolve({ data: mockStats });
});
renderLeaderboard();
await waitFor(() => {
expect(
screen.getByText(/no users to display yet/i)
).toBeInTheDocument();
});
});
it("should display friends-specific message when friends leaderboard is empty", async () => {
axios.get.mockImplementation((url) => {
if (url.includes("/api/leaderboard/friends")) {
return Promise.resolve({ data: [] });
}
if (url.includes("/api/leaderboard/")) {
return Promise.resolve({ data: mockLeaderboardData });
}
return Promise.resolve({ data: mockStats });
});
renderLeaderboard();
await waitFor(() => {
expect(screen.getByText("TopUser")).toBeInTheDocument();
});
const friendsTab = screen.getByRole("button", { name: /friends/i });
fireEvent.click(friendsTab);
await waitFor(() => {
expect(
screen.getByText(/no friends to display/i)
).toBeInTheDocument();
});
});
});
});
+83
View File
@@ -0,0 +1,83 @@
import React from "react";
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
const ActivityChart = ({ data, groupBy }) => {
// Format period labels based on groupBy
const formatPeriod = (period) => {
if (!period) return "";
if (groupBy === "month") {
const [year, month] = period.split("-");
const date = new Date(year, parseInt(month) - 1);
return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
} else if (groupBy === "week") {
return new Date(period).toLocaleDateString("en-US", { month: "short", day: "numeric" });
} else {
return new Date(period).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
};
const chartData = data.map((item) => ({
period: formatPeriod(item.period),
Tasks: item.tasks,
Posts: item.posts,
Events: item.events,
"Streets Adopted": item.streetsAdopted,
}));
return (
<div>
<div className="mb-4">
<h6>Activity Trends</h6>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="period" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="Tasks" stroke="#0d6efd" strokeWidth={2} />
<Line type="monotone" dataKey="Posts" stroke="#198754" strokeWidth={2} />
<Line type="monotone" dataKey="Events" stroke="#ffc107" strokeWidth={2} />
<Line
type="monotone"
dataKey="Streets Adopted"
stroke="#dc3545"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div>
<h6>Activity Comparison</h6>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="period" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="Tasks" fill="#0d6efd" />
<Bar dataKey="Posts" fill="#198754" />
<Bar dataKey="Events" fill="#ffc107" />
<Bar dataKey="Streets Adopted" fill="#dc3545" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default ActivityChart;
+182
View File
@@ -0,0 +1,182 @@
.analytics-container {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.analytics-header {
margin-bottom: 30px;
}
.analytics-header h2 {
color: #333;
margin-bottom: 10px;
}
.analytics-header p {
color: #666;
font-size: 14px;
}
.analytics-tabs {
display: flex;
gap: 10px;
margin-bottom: 30px;
border-bottom: 2px solid #e0e0e0;
flex-wrap: wrap;
}
.analytics-tab {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 16px;
color: #666;
transition: all 0.3s ease;
margin-bottom: -2px;
}
.analytics-tab:hover {
color: #333;
background-color: #f5f5f5;
}
.analytics-tab.active {
color: #28a745;
border-bottom-color: #28a745;
font-weight: 600;
}
.timeframe-selector {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.timeframe-btn {
padding: 8px 16px;
border: 2px solid #e0e0e0;
background: white;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.3s ease;
}
.timeframe-btn:hover {
border-color: #28a745;
color: #28a745;
}
.timeframe-btn.active {
background-color: #28a745;
color: white;
border-color: #28a745;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.stat-card h3 {
font-size: 14px;
color: #666;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .stat-value {
font-size: 36px;
font-weight: bold;
color: #28a745;
margin-bottom: 5px;
}
.stat-card .stat-label {
font-size: 12px;
color: #999;
}
.charts-section {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
.charts-section h3 {
color: #333;
margin-bottom: 20px;
font-size: 20px;
}
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
padding: 60px;
font-size: 18px;
color: #666;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid #f5c6cb;
}
.no-data {
text-align: center;
padding: 40px;
color: #999;
font-size: 16px;
}
@media (max-width: 768px) {
.analytics-container {
padding: 10px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.stat-card .stat-value {
font-size: 28px;
}
.analytics-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.analytics-tab {
padding: 10px 16px;
font-size: 14px;
}
}
+325
View File
@@ -0,0 +1,325 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import ActivityChart from "./ActivityChart";
import ContributorsList from "./ContributorsList";
import StreetStatsChart from "./StreetStatsChart";
import PersonalStats from "./PersonalStats";
import "./Analytics.css";
const Analytics = () => {
const [overview, setOverview] = useState(null);
const [activity, setActivity] = useState(null);
const [contributors, setContributors] = useState([]);
const [streetStats, setStreetStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [timeframe, setTimeframe] = useState("30d");
const [groupBy, setGroupBy] = useState("day");
const [activeTab, setActiveTab] = useState("overview");
const fetchAnalyticsData = async () => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem("token");
const config = {
headers: {
"x-auth-token": token,
},
};
const [overviewRes, activityRes, contributorsRes, streetStatsRes] = await Promise.all([
axios.get(`/api/analytics/overview?timeframe=${timeframe}`, config),
axios.get(`/api/analytics/activity?timeframe=${timeframe}&groupBy=${groupBy}`, config),
axios.get(`/api/analytics/top-contributors?limit=10&timeframe=${timeframe}`, config),
axios.get(`/api/analytics/street-stats?timeframe=${timeframe}`, config),
]);
setOverview(overviewRes.data.overview);
setActivity(activityRes.data);
setContributors(contributorsRes.data.contributors);
setStreetStats(streetStatsRes.data);
} catch (err) {
console.error("Error fetching analytics:", err);
setError(err.response?.data?.msg || "Failed to load analytics data");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAnalyticsData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeframe, groupBy]);
const handleTimeframeChange = (e) => {
setTimeframe(e.target.value);
};
const handleGroupByChange = (e) => {
setGroupBy(e.target.value);
};
if (loading) {
return (
<div className="analytics-container">
<div className="text-center mt-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p className="mt-3">Loading analytics...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="analytics-container">
<div className="alert alert-danger mt-3" role="alert">
{error}
</div>
</div>
);
}
return (
<div className="analytics-container">
<div className="analytics-header">
<h1>Analytics Dashboard</h1>
<div className="analytics-controls">
<div className="form-group">
<label htmlFor="timeframe">Timeframe:</label>
<select
id="timeframe"
className="form-select"
value={timeframe}
onChange={handleTimeframeChange}
>
<option value="7d">Last 7 Days</option>
<option value="30d">Last 30 Days</option>
<option value="90d">Last 90 Days</option>
<option value="all">All Time</option>
</select>
</div>
{activeTab === "activity" && (
<div className="form-group">
<label htmlFor="groupBy">Group By:</label>
<select
id="groupBy"
className="form-select"
value={groupBy}
onChange={handleGroupByChange}
>
<option value="day">Day</option>
<option value="week">Week</option>
<option value="month">Month</option>
</select>
</div>
)}
</div>
</div>
<ul className="nav nav-tabs mb-4">
<li className="nav-item">
<button
className={`nav-link ${activeTab === "overview" ? "active" : ""}`}
onClick={() => setActiveTab("overview")}
>
Overview
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "activity" ? "active" : ""}`}
onClick={() => setActiveTab("activity")}
>
Activity
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "personal" ? "active" : ""}`}
onClick={() => setActiveTab("personal")}
>
My Stats
</button>
</li>
</ul>
{activeTab === "overview" && overview && (
<div className="overview-tab">
<div className="row">
<div className="col-md-3 mb-4">
<div className="stat-card">
<div className="stat-icon bg-primary">
<i className="fas fa-users"></i>
</div>
<div className="stat-content">
<h3>{overview.totalUsers}</h3>
<p>Total Users</p>
</div>
</div>
</div>
<div className="col-md-3 mb-4">
<div className="stat-card">
<div className="stat-icon bg-success">
<i className="fas fa-road"></i>
</div>
<div className="stat-content">
<h3>{overview.adoptedStreets}</h3>
<p>Streets Adopted</p>
<small className="text-muted">
{overview.availableStreets} available
</small>
</div>
</div>
</div>
<div className="col-md-3 mb-4">
<div className="stat-card">
<div className="stat-icon bg-info">
<i className="fas fa-tasks"></i>
</div>
<div className="stat-content">
<h3>{overview.completedTasks}</h3>
<p>Tasks Completed</p>
<small className="text-muted">
{overview.pendingTasks} pending
</small>
</div>
</div>
</div>
<div className="col-md-3 mb-4">
<div className="stat-card">
<div className="stat-icon bg-warning">
<i className="fas fa-calendar"></i>
</div>
<div className="stat-content">
<h3>{overview.activeEvents}</h3>
<p>Active Events</p>
<small className="text-muted">
{overview.completedEvents} completed
</small>
</div>
</div>
</div>
</div>
<div className="row mt-4">
<div className="col-md-3 mb-4">
<div className="stat-card">
<div className="stat-icon bg-secondary">
<i className="fas fa-comments"></i>
</div>
<div className="stat-content">
<h3>{overview.totalPosts}</h3>
<p>Total Posts</p>
</div>
</div>
</div>
<div className="col-md-3 mb-4">
<div className="stat-card">
<div className="stat-icon bg-danger">
<i className="fas fa-star"></i>
</div>
<div className="stat-content">
<h3>{overview.totalPoints.toLocaleString()}</h3>
<p>Total Points</p>
</div>
</div>
</div>
<div className="col-md-3 mb-4">
<div className="stat-card">
<div className="stat-icon bg-dark">
<i className="fas fa-chart-line"></i>
</div>
<div className="stat-content">
<h3>{overview.averagePointsPerUser}</h3>
<p>Avg Points/User</p>
</div>
</div>
</div>
</div>
<div className="row mt-4">
<div className="col-lg-8 mb-4">
<div className="card">
<div className="card-header">
<h5>Street Statistics</h5>
</div>
<div className="card-body">
{streetStats && <StreetStatsChart data={streetStats} />}
</div>
</div>
</div>
<div className="col-lg-4 mb-4">
<div className="card">
<div className="card-header">
<h5>Top Contributors</h5>
</div>
<div className="card-body">
<ContributorsList contributors={contributors} />
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === "activity" && activity && (
<div className="activity-tab">
<div className="card">
<div className="card-header">
<h5>Activity Over Time</h5>
</div>
<div className="card-body">
<ActivityChart data={activity.activity} groupBy={groupBy} />
</div>
</div>
<div className="row mt-4">
<div className="col-md-3">
<div className="stat-card small">
<h4>{activity.summary.totalTasks}</h4>
<p>Total Tasks</p>
</div>
</div>
<div className="col-md-3">
<div className="stat-card small">
<h4>{activity.summary.totalPosts}</h4>
<p>Total Posts</p>
</div>
</div>
<div className="col-md-3">
<div className="stat-card small">
<h4>{activity.summary.totalEvents}</h4>
<p>Total Events</p>
</div>
</div>
<div className="col-md-3">
<div className="stat-card small">
<h4>{activity.summary.totalStreetsAdopted}</h4>
<p>Streets Adopted</p>
</div>
</div>
</div>
</div>
)}
{activeTab === "personal" && (
<div className="personal-tab">
<PersonalStats timeframe={timeframe} />
</div>
)}
</div>
);
};
export default Analytics;
+188
View File
@@ -0,0 +1,188 @@
import React, { useState, useEffect, useContext } from "react";
import axios from "axios";
import { toast } from "react-toastify";
import BadgeDisplay from "./BadgeDisplay";
import { AuthContext } from "../context/AuthContext";
/**
* BadgeCollection component - displays all available badges with filter/sort options
*/
const BadgeCollection = () => {
const { auth } = useContext(AuthContext);
const [badges, setBadges] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filter, setFilter] = useState("all"); // all, earned, locked
const [sortBy, setSortBy] = useState("rarity"); // rarity, name
useEffect(() => {
const fetchBadges = async () => {
try {
setLoading(true);
setError(null);
// Fetch all badges and user's progress
const [allBadgesRes, progressRes] = await Promise.all([
axios.get("/api/badges"),
axios.get("/api/badges/progress"),
]);
// Merge badge data with progress data
const badgesWithProgress = allBadgesRes.data.map((badge) => {
const progress = progressRes.data.find((p) => p._id === badge._id);
return {
...badge,
isEarned: progress?.isEarned || false,
progress: progress?.progress || 0,
threshold: progress?.threshold || badge.criteria?.threshold || 0,
};
});
setBadges(badgesWithProgress);
} catch (err) {
console.error("Error fetching badges:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to load badges";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
if (auth.isAuthenticated) {
fetchBadges();
}
}, [auth.isAuthenticated]);
// Filter badges based on selected filter
const getFilteredBadges = () => {
let filtered = [...badges];
if (filter === "earned") {
filtered = filtered.filter((badge) => badge.isEarned);
} else if (filter === "locked") {
filtered = filtered.filter((badge) => !badge.isEarned);
}
return filtered;
};
// Sort badges based on selected sort option
const getSortedBadges = () => {
const filtered = getFilteredBadges();
if (sortBy === "rarity") {
const rarityOrder = { legendary: 0, epic: 1, rare: 2, common: 3 };
return filtered.sort(
(a, b) => rarityOrder[a.rarity] - rarityOrder[b.rarity]
);
} else if (sortBy === "name") {
return filtered.sort((a, b) => a.name.localeCompare(b.name));
}
return filtered;
};
const sortedBadges = getSortedBadges();
const earnedCount = badges.filter((b) => b.isEarned).length;
const totalCount = badges.length;
if (loading) {
return (
<div className="text-center mt-5">
<div className="spinner-border" role="status">
<span className="sr-only">Loading...</span>
</div>
<p>Loading badges...</p>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger m-3" role="alert">
<h4 className="alert-heading">Error Loading Badges</h4>
<p>{error}</p>
</div>
);
}
if (!auth.isAuthenticated) {
return (
<div className="alert alert-warning m-3" role="alert">
<h4 className="alert-heading">Not Logged In</h4>
<p>Please log in to view badges.</p>
</div>
);
}
return (
<div className="badge-collection">
<div className="d-flex justify-content-between align-items-center mb-4">
<h2>Badge Collection</h2>
<div>
<span className="badge badge-primary badge-lg">
{earnedCount} / {totalCount} Earned
</span>
</div>
</div>
{/* Filter and Sort Controls */}
<div className="card mb-4">
<div className="card-body">
<div className="row">
<div className="col-md-6">
<label htmlFor="filterSelect" className="form-label">
<strong>Filter:</strong>
</label>
<select
id="filterSelect"
className="form-control"
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
<option value="all">All Badges</option>
<option value="earned">Earned Only</option>
<option value="locked">Locked Only</option>
</select>
</div>
<div className="col-md-6">
<label htmlFor="sortSelect" className="form-label">
<strong>Sort By:</strong>
</label>
<select
id="sortSelect"
className="form-control"
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
>
<option value="rarity">Rarity</option>
<option value="name">Name</option>
</select>
</div>
</div>
</div>
</div>
{/* Badge Grid */}
{sortedBadges.length === 0 ? (
<div className="alert alert-info">
<p className="mb-0">No badges found with the selected filter.</p>
</div>
) : (
<div className="row">
{sortedBadges.map((badge) => (
<div key={badge._id} className="col-md-4 col-lg-3 mb-4">
<BadgeDisplay badge={badge} isEarned={badge.isEarned} />
</div>
))}
</div>
)}
</div>
);
};
export default BadgeCollection;
+88
View File
@@ -0,0 +1,88 @@
import React from "react";
import PropTypes from "prop-types";
/**
* BadgeDisplay component - displays a single badge with icon, name, and description
* @param {Object} badge - Badge object
* @param {boolean} isEarned - Whether the badge is earned
* @param {boolean} showTooltip - Whether to show tooltip on hover
*/
const BadgeDisplay = ({ badge, isEarned = false, showTooltip = true }) => {
const getRarityColor = (rarity) => {
switch (rarity) {
case "common":
return "#6c757d";
case "rare":
return "#0d6efd";
case "epic":
return "#6f42c1";
case "legendary":
return "#ffc107";
default:
return "#6c757d";
}
};
const badgeStyle = {
filter: isEarned ? "none" : "grayscale(100%) opacity(0.5)",
borderColor: getRarityColor(badge.rarity),
borderWidth: "3px",
transition: "all 0.3s ease",
};
const iconStyle = {
fontSize: "3rem",
marginBottom: "0.5rem",
};
return (
<div
className="card badge-card text-center p-3"
style={badgeStyle}
title={
showTooltip
? `${badge.name} - ${badge.description}\n${
badge.criteria?.type
? `Unlock: ${badge.criteria.threshold} ${badge.criteria.type.replace(
"_",
" "
)}`
: ""
}`
: ""
}
>
<div style={iconStyle}>{badge.icon || "🏆"}</div>
<h6 className="mb-1">{badge.name}</h6>
<p className="small text-muted mb-1">{badge.description}</p>
<span
className={`badge badge-${badge.rarity === "legendary" ? "warning" : badge.rarity === "epic" ? "purple" : badge.rarity === "rare" ? "primary" : "secondary"}`}
>
{badge.rarity}
</span>
{isEarned && (
<div className="mt-2">
<span className="badge badge-success"> Earned</span>
</div>
)}
</div>
);
};
BadgeDisplay.propTypes = {
badge: PropTypes.shape({
_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
icon: PropTypes.string,
rarity: PropTypes.oneOf(["common", "rare", "epic", "legendary"]).isRequired,
criteria: PropTypes.shape({
type: PropTypes.string,
threshold: PropTypes.number,
}),
}).isRequired,
isEarned: PropTypes.bool,
showTooltip: PropTypes.bool,
};
export default BadgeDisplay;
+79
View File
@@ -0,0 +1,79 @@
import React from "react";
import PropTypes from "prop-types";
/**
* BadgeProgress component - displays progress bars for badges in progress
* @param {Array} badges - Array of badge objects with progress information
*/
const BadgeProgress = ({ badges }) => {
// Filter to show only badges that are in progress (not earned and have some progress)
const inProgressBadges = badges.filter(
(badge) => !badge.isEarned && badge.progress > 0 && badge.threshold > 0
);
if (inProgressBadges.length === 0) {
return (
<div className="alert alert-info">
<p className="mb-0">
Complete tasks and participate in events to earn badges!
</p>
</div>
);
}
return (
<div className="badge-progress-container">
<h5 className="mb-3">Badges In Progress</h5>
{inProgressBadges.map((badge) => {
const percentage = Math.round((badge.progress / badge.threshold) * 100);
return (
<div key={badge._id} className="mb-3">
<div className="d-flex justify-content-between align-items-center mb-1">
<div className="d-flex align-items-center">
<span style={{ fontSize: "1.5rem", marginRight: "0.5rem" }}>
{badge.icon || "🏆"}
</span>
<div>
<strong>{badge.name}</strong>
<br />
<small className="text-muted">{badge.description}</small>
</div>
</div>
<span className="badge badge-info">
{badge.progress} / {badge.threshold}
</span>
</div>
<div className="progress" style={{ height: "20px" }}>
<div
className={`progress-bar ${percentage >= 75 ? "bg-success" : percentage >= 50 ? "bg-info" : "bg-warning"}`}
role="progressbar"
style={{ width: `${percentage}%` }}
aria-valuenow={badge.progress}
aria-valuemin="0"
aria-valuemax={badge.threshold}
>
{percentage}%
</div>
</div>
</div>
);
})}
</div>
);
};
BadgeProgress.propTypes = {
badges: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
icon: PropTypes.string,
progress: PropTypes.number.isRequired,
threshold: PropTypes.number.isRequired,
isEarned: PropTypes.bool.isRequired,
})
).isRequired,
};
export default BadgeProgress;
@@ -0,0 +1,159 @@
.contributors-list {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
.contributors-list h3 {
color: #333;
margin-bottom: 20px;
font-size: 20px;
}
.metric-selector {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.metric-btn {
padding: 8px 16px;
border: 2px solid #e0e0e0;
background: white;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.3s ease;
text-transform: capitalize;
}
.metric-btn:hover {
border-color: #28a745;
color: #28a745;
}
.metric-btn.active {
background-color: #28a745;
color: white;
border-color: #28a745;
}
.contributors-table {
width: 100%;
border-collapse: collapse;
}
.contributors-table thead {
background-color: #f8f9fa;
}
.contributors-table th {
padding: 12px;
text-align: left;
font-weight: 600;
color: #666;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid #e0e0e0;
}
.contributors-table td {
padding: 15px 12px;
border-bottom: 1px solid #f0f0f0;
}
.contributors-table tbody tr {
transition: background-color 0.3s ease;
}
.contributors-table tbody tr:hover {
background-color: #f8f9fa;
}
.rank-cell {
font-weight: bold;
color: #999;
width: 60px;
}
.rank-cell.rank-1 {
color: #ffd700;
font-size: 18px;
}
.rank-cell.rank-2 {
color: #c0c0c0;
font-size: 16px;
}
.rank-cell.rank-3 {
color: #cd7f32;
font-size: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #28a745;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
}
.user-name {
font-weight: 500;
color: #333;
}
.value-cell {
font-weight: 600;
color: #28a745;
font-size: 16px;
}
.no-contributors {
text-align: center;
padding: 30px;
color: #999;
font-size: 14px;
}
@media (max-width: 768px) {
.contributors-list {
padding: 15px;
}
.contributors-table {
font-size: 14px;
}
.contributors-table th,
.contributors-table td {
padding: 10px 8px;
}
.user-avatar {
width: 32px;
height: 32px;
font-size: 14px;
}
.rank-cell {
width: 40px;
}
}
@@ -0,0 +1,50 @@
import React from "react";
import "./ContributorsList.css";
const ContributorsList = ({ contributors }) => {
if (!contributors || contributors.length === 0) {
return <p className="text-muted">No contributors data available.</p>;
}
return (
<div className="contributors-list">
{contributors.map((contributor, index) => (
<div key={contributor.userId} className="contributor-item">
<div className="contributor-rank">#{index + 1}</div>
<div className="contributor-avatar">
{contributor.profilePicture ? (
<img
src={contributor.profilePicture}
alt={contributor.name}
className="avatar-img"
/>
) : (
<div className="avatar-placeholder">
{contributor.name.charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="contributor-info">
<div className="contributor-name">
{contributor.name}
{contributor.isPremium && (
<span className="badge bg-warning text-dark ms-2">Premium</span>
)}
</div>
<div className="contributor-stats">
<small className="text-muted">
{contributor.stats.points} pts | {contributor.stats.tasksCompleted} tasks |{" "}
{contributor.stats.streetsAdopted} streets
</small>
</div>
</div>
<div className="contributor-score">
<strong>{contributor.score}</strong>
</div>
</div>
))}
</div>
);
};
export default ContributorsList;
+263
View File
@@ -0,0 +1,263 @@
import React, { useState, useEffect, useContext, useCallback } from "react";
import axios from "axios";
import { toast } from "react-toastify";
import { AuthContext } from "../context/AuthContext";
import LeaderboardCard from "./LeaderboardCard";
/**
* Leaderboard component displays top users by points with different timeframes
*/
const Leaderboard = () => {
const { auth } = useContext(AuthContext);
const [activeTab, setActiveTab] = useState("global");
const [leaderboard, setLeaderboard] = useState([]);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const limit = 50;
// Load leaderboard data based on active tab
const loadLeaderboard = useCallback(async () => {
try {
setLoading(true);
setError(null);
const offset = (page - 1) * limit;
let endpoint = "";
switch (activeTab) {
case "global":
endpoint = `/api/leaderboard/global?limit=${limit}&offset=${offset}`;
break;
case "weekly":
endpoint = `/api/leaderboard/weekly?limit=${limit}&offset=${offset}`;
break;
case "monthly":
endpoint = `/api/leaderboard/monthly?limit=${limit}&offset=${offset}`;
break;
case "friends":
endpoint = `/api/leaderboard/friends?limit=${limit}&offset=${offset}`;
break;
default:
endpoint = `/api/leaderboard/global?limit=${limit}&offset=${offset}`;
}
const config = {};
// Friends endpoint requires authentication
if (activeTab === "friends") {
const token = localStorage.getItem("token");
config.headers = { "x-auth-token": token };
}
const res = await axios.get(endpoint, config);
setLeaderboard(res.data);
setHasMore(res.data.length === limit);
} catch (err) {
console.error("Error loading leaderboard:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to load leaderboard. Please try again later.";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
}, [activeTab, page]);
// Load leaderboard stats
const loadStats = useCallback(async () => {
try {
const res = await axios.get("/api/leaderboard/stats");
setStats(res.data);
} catch (err) {
console.error("Error loading leaderboard stats:", err);
}
}, []);
useEffect(() => {
loadLeaderboard();
}, [loadLeaderboard]);
useEffect(() => {
loadStats();
}, [loadStats]);
// Handle tab change
const handleTabChange = (tab) => {
if (tab === "friends" && !auth.isAuthenticated) {
toast.warning("Please login to view friends leaderboard");
return;
}
setActiveTab(tab);
setPage(1);
setLeaderboard([]);
};
// Handle pagination
const handlePreviousPage = () => {
if (page > 1) {
setPage(page - 1);
}
};
const handleNextPage = () => {
if (hasMore) {
setPage(page + 1);
}
};
if (loading && leaderboard.length === 0) {
return (
<div className="text-center mt-5">
<div className="spinner-border" role="status">
<span className="sr-only">Loading...</span>
</div>
<p>Loading leaderboard...</p>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger m-3" role="alert">
<h4 className="alert-heading">Error Loading Leaderboard</h4>
<p>{error}</p>
<hr />
<button className="btn btn-primary" onClick={loadLeaderboard}>
Retry
</button>
</div>
);
}
return (
<div>
<h1>Leaderboard</h1>
{/* Leaderboard Stats */}
{stats && (
<div className="alert alert-info mb-4">
<div className="row">
<div className="col-md-3 col-6 mb-2">
<strong>Total Users:</strong> {stats.totalUsers}
</div>
<div className="col-md-3 col-6 mb-2">
<strong>Total Points:</strong> {stats.totalPoints.toLocaleString()}
</div>
<div className="col-md-3 col-6 mb-2">
<strong>Avg Points:</strong> {Math.round(stats.averagePoints)}
</div>
<div className="col-md-3 col-6 mb-2">
<strong>Top Score:</strong> {stats.maxPoints.toLocaleString()}
</div>
</div>
</div>
)}
{/* Tab Navigation */}
<ul className="nav nav-tabs mb-4">
<li className="nav-item">
<button
className={`nav-link ${activeTab === "global" ? "active" : ""}`}
onClick={() => handleTabChange("global")}
>
All Time
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "weekly" ? "active" : ""}`}
onClick={() => handleTabChange("weekly")}
>
This Week
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "monthly" ? "active" : ""}`}
onClick={() => handleTabChange("monthly")}
>
This Month
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "friends" ? "active" : ""}`}
onClick={() => handleTabChange("friends")}
disabled={!auth.isAuthenticated}
>
Friends {!auth.isAuthenticated && "(Login Required)"}
</button>
</li>
</ul>
{/* Current User Info */}
{auth.user && activeTab !== "friends" && (
<div className="alert alert-success mb-4">
<strong>Your Points:</strong>{" "}
<span className="badge badge-primary">{auth.user.points || 0}</span>
</div>
)}
{/* Leaderboard List */}
{leaderboard.length === 0 ? (
<div className="alert alert-info">
{activeTab === "friends"
? "No friends to display. Add friends to see them on the leaderboard!"
: "No users to display yet. Be the first to earn points!"}
</div>
) : (
<>
<div className="row">
{leaderboard.map((entry, index) => (
<LeaderboardCard
key={entry.userId}
entry={entry}
rank={(page - 1) * limit + index + 1}
isCurrentUser={auth.user && entry.userId === auth.user._id}
/>
))}
</div>
{/* Pagination */}
<div className="d-flex justify-content-between align-items-center mt-4">
<button
className="btn btn-secondary"
onClick={handlePreviousPage}
disabled={page === 1 || loading}
>
Previous
</button>
<span>
Page {page} {hasMore && "- More available"}
</span>
<button
className="btn btn-secondary"
onClick={handleNextPage}
disabled={!hasMore || loading}
>
{loading ? (
<>
<span
className="spinner-border spinner-border-sm mr-2"
role="status"
aria-hidden="true"
></span>
Loading...
</>
) : (
"Next"
)}
</button>
</div>
</>
)}
</div>
);
};
export default Leaderboard;
+103
View File
@@ -0,0 +1,103 @@
import React from "react";
/**
* LeaderboardCard component displays individual user entry on the leaderboard
* @param {Object} entry - Leaderboard entry data
* @param {Number} rank - User's rank/position
* @param {Boolean} isCurrentUser - Whether this is the logged-in user
*/
const LeaderboardCard = ({ entry, rank, isCurrentUser }) => {
// Determine rank badge color
const getRankBadgeClass = () => {
if (rank === 1) return "badge-warning"; // Gold
if (rank === 2) return "badge-secondary"; // Silver
if (rank === 3) return "badge-danger"; // Bronze
return "badge-primary"; // Default
};
// Get rank emoji
const getRankEmoji = () => {
if (rank === 1) return "🥇";
if (rank === 2) return "🥈";
if (rank === 3) return "🥉";
return "";
};
return (
<div className="col-12 mb-3">
<div
className={`card h-100 ${isCurrentUser ? "border-success" : ""}`}
style={isCurrentUser ? { borderWidth: "3px" } : {}}
>
<div className="card-body">
<div className="row align-items-center">
{/* Rank */}
<div className="col-2 col-md-1 text-center">
<h3 className="mb-0">
<span className={`badge ${getRankBadgeClass()}`}>
{getRankEmoji()} #{rank}
</span>
</h3>
</div>
{/* User Info */}
<div className="col-10 col-md-5">
<h5 className="mb-1">
{entry.username || "Unknown User"}
{isCurrentUser && (
<span className="badge badge-success ml-2">You</span>
)}
</h5>
<div className="text-muted small">
{entry.email && <div>{entry.email}</div>}
</div>
</div>
{/* Points */}
<div className="col-6 col-md-2 text-center mt-2 mt-md-0">
<div className="text-muted small">Points</div>
<h4 className="mb-0 text-primary">
{entry.points.toLocaleString()}
</h4>
</div>
{/* Stats */}
<div className="col-6 col-md-2 text-center mt-2 mt-md-0">
<div className="text-muted small">Streets</div>
<div className="font-weight-bold">{entry.streetsAdopted || 0}</div>
<div className="text-muted small mt-1">Tasks</div>
<div className="font-weight-bold">{entry.tasksCompleted || 0}</div>
</div>
{/* Badges */}
<div className="col-12 col-md-2 text-center mt-2 mt-md-0">
<div className="text-muted small">Badges</div>
<div className="d-flex justify-content-center flex-wrap">
{entry.badges && entry.badges.length > 0 ? (
entry.badges.slice(0, 5).map((badge, index) => (
<span
key={index}
className="badge badge-info mr-1 mb-1"
title={badge.name}
>
{badge.icon || "🏆"}
</span>
))
) : (
<span className="text-muted small">None</span>
)}
{entry.badges && entry.badges.length > 5 && (
<span className="badge badge-secondary ml-1">
+{entry.badges.length - 5}
</span>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default LeaderboardCard;
+6
View File
@@ -22,6 +22,12 @@ const Navbar = () => {
<li className="nav-item">
<Link className="nav-link" to="/rewards">Rewards</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/leaderboard">Leaderboard</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/analytics">Analytics</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/profile">Profile</Link>
</li>
+190
View File
@@ -0,0 +1,190 @@
.personal-stats {
padding: 20px 0;
}
.personal-stats-header {
text-align: center;
margin-bottom: 30px;
}
.personal-stats-header h3 {
color: #333;
font-size: 24px;
margin-bottom: 10px;
}
.personal-stats-header p {
color: #666;
font-size: 14px;
}
.personal-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.personal-stat-card {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
border-radius: 12px;
padding: 24px;
color: white;
box-shadow: 0 4px 6px rgba(40, 167, 69, 0.2);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.personal-stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 12px rgba(40, 167, 69, 0.3);
}
.personal-stat-card h4 {
font-size: 14px;
opacity: 0.9;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 500;
}
.personal-stat-card .value {
font-size: 42px;
font-weight: bold;
margin-bottom: 5px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.personal-stat-card .label {
font-size: 12px;
opacity: 0.8;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 30px;
}
.chart-container {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.chart-container h4 {
color: #333;
margin-bottom: 20px;
font-size: 18px;
font-weight: 600;
}
.activity-timeline {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-top: 30px;
}
.activity-timeline h4 {
color: #333;
margin-bottom: 20px;
font-size: 18px;
font-weight: 600;
}
.breakdown-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-top: 20px;
}
.breakdown-item {
background: #f8f9fa;
border-radius: 8px;
padding: 15px;
text-align: center;
}
.breakdown-item .value {
font-size: 24px;
font-weight: bold;
color: #28a745;
margin-bottom: 5px;
}
.breakdown-item .label {
font-size: 12px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.achievement-badge {
display: inline-block;
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
color: #333;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
margin-top: 15px;
box-shadow: 0 2px 4px rgba(255, 215, 0, 0.3);
}
.loading-personal-stats {
text-align: center;
padding: 60px;
color: #666;
font-size: 16px;
}
.no-personal-data {
text-align: center;
padding: 60px;
color: #999;
font-size: 16px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.no-personal-data p {
margin-bottom: 20px;
}
.get-started-btn {
background-color: #28a745;
color: white;
border: none;
padding: 12px 24px;
border-radius: 24px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.get-started-btn:hover {
background-color: #218838;
}
@media (max-width: 768px) {
.personal-stats-grid {
grid-template-columns: 1fr;
}
.personal-stat-card .value {
font-size: 32px;
}
.charts-grid {
grid-template-columns: 1fr;
}
.breakdown-stats {
grid-template-columns: 1fr 1fr;
}
}
+289
View File
@@ -0,0 +1,289 @@
import React, { useState, useEffect, useContext } from "react";
import axios from "axios";
import { AuthContext } from "../context/AuthContext";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import "./PersonalStats.css";
const PersonalStats = ({ timeframe }) => {
const { user } = useContext(AuthContext);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchPersonalStats = async () => {
if (!user) return;
setLoading(true);
setError(null);
try {
const token = localStorage.getItem("token");
const config = {
headers: {
"x-auth-token": token,
},
};
const res = await axios.get(
`/api/analytics/user/${user._id}?timeframe=${timeframe}`,
config
);
setStats(res.data);
} catch (err) {
console.error("Error fetching personal stats:", err);
setError(err.response?.data?.msg || "Failed to load personal statistics");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPersonalStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeframe, user]);
if (loading) {
return (
<div className="text-center">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger" role="alert">
{error}
</div>
);
}
if (!stats) return null;
const chartData = [
{ name: "Streets", value: stats.stats.streetsAdopted },
{ name: "Tasks", value: stats.stats.tasksCompleted },
{ name: "Posts", value: stats.stats.postsCreated },
{ name: "Events", value: stats.stats.eventsParticipated },
{ name: "Badges", value: stats.stats.badgesEarned },
];
return (
<div className="personal-stats">
<div className="row mb-4">
<div className="col-md-12">
<div className="user-header">
<h3>{stats.user.name}</h3>
<div className="user-badges">
{stats.user.isPremium && (
<span className="badge bg-warning text-dark">Premium</span>
)}
<span className="badge bg-primary">{stats.user.points} Points</span>
</div>
</div>
</div>
</div>
<div className="row mb-4">
<div className="col-md-2">
<div className="stat-box">
<h4>{stats.stats.streetsAdopted}</h4>
<p>Streets</p>
</div>
</div>
<div className="col-md-2">
<div className="stat-box">
<h4>{stats.stats.tasksCompleted}</h4>
<p>Tasks</p>
</div>
</div>
<div className="col-md-2">
<div className="stat-box">
<h4>{stats.stats.postsCreated}</h4>
<p>Posts</p>
</div>
</div>
<div className="col-md-2">
<div className="stat-box">
<h4>{stats.stats.eventsParticipated}</h4>
<p>Events</p>
</div>
</div>
<div className="col-md-2">
<div className="stat-box">
<h4>{stats.stats.badgesEarned}</h4>
<p>Badges</p>
</div>
</div>
<div className="col-md-2">
<div className="stat-box">
<h4>{stats.stats.totalLikesReceived}</h4>
<p>Likes</p>
</div>
</div>
</div>
<div className="row mb-4">
<div className="col-md-6">
<div className="card">
<div className="card-header">
<h5>Activity Overview</h5>
</div>
<div className="card-body">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="value" fill="#0d6efd" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card">
<div className="card-header">
<h5>Points Summary</h5>
</div>
<div className="card-body">
<div className="points-summary">
<div className="points-row">
<span className="points-label">Points Earned:</span>
<span className="points-value text-success">
+{stats.stats.pointsEarned}
</span>
</div>
<div className="points-row">
<span className="points-label">Points Spent:</span>
<span className="points-value text-danger">
-{stats.stats.pointsSpent}
</span>
</div>
<hr />
<div className="points-row">
<span className="points-label">
<strong>Current Balance:</strong>
</span>
<span className="points-value">
<strong>{stats.user.points}</strong>
</span>
</div>
</div>
<div className="mt-4">
<h6>Engagement Metrics</h6>
<div className="engagement-metrics">
<div className="metric">
<span className="metric-icon">
<i className="fas fa-heart"></i>
</span>
<span className="metric-value">
{stats.stats.totalLikesReceived}
</span>
<span className="metric-label">Likes Received</span>
</div>
<div className="metric">
<span className="metric-icon">
<i className="fas fa-comment"></i>
</span>
<span className="metric-value">
{stats.stats.totalCommentsReceived}
</span>
<span className="metric-label">Comments Received</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{stats.recentActivity && (
<div className="row">
<div className="col-md-12">
<div className="card">
<div className="card-header">
<h5>Recent Activity</h5>
</div>
<div className="card-body">
<div className="row">
<div className="col-md-4">
<h6>Recent Tasks</h6>
{stats.recentActivity.tasks.length > 0 ? (
<ul className="list-unstyled">
{stats.recentActivity.tasks.map((task) => (
<li key={task._id} className="mb-2">
<small className="text-muted">
{new Date(task.completedAt || task.createdAt).toLocaleDateString()}
</small>
<div>{task.description}</div>
</li>
))}
</ul>
) : (
<p className="text-muted">No recent tasks</p>
)}
</div>
<div className="col-md-4">
<h6>Recent Posts</h6>
{stats.recentActivity.posts.length > 0 ? (
<ul className="list-unstyled">
{stats.recentActivity.posts.map((post) => (
<li key={post._id} className="mb-2">
<small className="text-muted">
{new Date(post.createdAt).toLocaleDateString()}
</small>
<div>{post.content.substring(0, 50)}...</div>
</li>
))}
</ul>
) : (
<p className="text-muted">No recent posts</p>
)}
</div>
<div className="col-md-4">
<h6>Recent Events</h6>
{stats.recentActivity.events.length > 0 ? (
<ul className="list-unstyled">
{stats.recentActivity.events.map((event) => (
<li key={event._id} className="mb-2">
<small className="text-muted">
{new Date(event.date).toLocaleDateString()}
</small>
<div>{event.title}</div>
</li>
))}
</ul>
) : (
<p className="text-muted">No recent events</p>
)}
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default PersonalStats;
+137
View File
@@ -0,0 +1,137 @@
import React from "react";
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts";
const StreetStatsChart = ({ data }) => {
if (!data) return null;
const adoptionData = [
{ name: "Adopted", value: data.adoption.adoptedStreets, color: "#198754" },
{ name: "Available", value: data.adoption.availableStreets, color: "#6c757d" },
];
const taskData = [
{ name: "Completed", value: data.tasks.completedTasks, color: "#0d6efd" },
{ name: "Pending", value: data.tasks.pendingTasks, color: "#ffc107" },
{ name: "In Progress", value: data.tasks.inProgressTasks, color: "#fd7e14" },
];
const RADIAN = Math.PI / 180;
const renderCustomizedLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
percent,
}) => {
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
return (
<text
x={x}
y={y}
fill="white"
textAnchor={x > cx ? "start" : "end"}
dominantBaseline="central"
fontWeight="bold"
>
{`${(percent * 100).toFixed(0)}%`}
</text>
);
};
return (
<div>
<div className="row mb-4">
<div className="col-md-4">
<div className="stat-highlight">
<h3>{data.adoption.adoptionRate}%</h3>
<p className="text-muted">Adoption Rate</p>
</div>
</div>
<div className="col-md-4">
<div className="stat-highlight">
<h3>{data.tasks.completionRate}%</h3>
<p className="text-muted">Task Completion Rate</p>
</div>
</div>
<div className="col-md-4">
<div className="stat-highlight">
<h3>{data.adoption.totalStreets}</h3>
<p className="text-muted">Total Streets</p>
</div>
</div>
</div>
<div className="row">
<div className="col-md-6">
<h6 className="text-center mb-3">Street Adoption</h6>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={adoptionData}
cx="50%"
cy="50%"
labelLine={false}
label={renderCustomizedLabel}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{adoptionData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
<div className="col-md-6">
<h6 className="text-center mb-3">Task Status</h6>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={taskData}
cx="50%"
cy="50%"
labelLine={false}
label={renderCustomizedLabel}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{taskData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
{data.topStreets && data.topStreets.length > 0 && (
<div className="mt-4">
<h6>Top Streets by Task Completion</h6>
<div className="list-group">
{data.topStreets.slice(0, 5).map((street, index) => (
<div key={street.streetId} className="list-group-item d-flex justify-content-between align-items-center">
<span>
<strong>#{index + 1}</strong> {street.streetName}
</span>
<span className="badge bg-primary rounded-pill">{street.count} tasks</span>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default StreetStatsChart;
@@ -0,0 +1,71 @@
import React, { useState, useEffect, useContext } from \"react\";
import { useParams, Link } from \"react-router-dom\";
import axios from \"axios\";
const { AuthContext } = require(\"../../context/AuthContext\");
const ProfileView = () => {
const { userId } = useParams();
const { auth } = useContext(AuthContext);
const [profile, setProfile] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchProfile = async () => {
try {
const res = await axios.get((/api/profile/${userId}`);
setProfile(res.data);
} catch (err) {
setError(err.response?.ager = == auth.user._id;
return (
<div className=\"container mt-5\">
<div className=\"row\">
<div className=\"col-md-4 text-center\">
<img
src={`npame"} id: auth.user._id });
setProfile(res.data);
} catch (err) {
setError(err.response?.data?.msg || \"Error fetching profile\");
}
setLoading(false);
};
fetchProfile();
}, [userId]);
if (loading) {
return <div className=\"container\"><p>Loading profile...</p></div>;
}
if (error) {
return <div className=\"container\"><div className=\"alert alert-danger\">{error}</div></div>;
}
if (!profile) {
return <div className=\"container\"><p>Profile not found.</p></div>;
}
const { name, avatar, bio, location, website, social, preferences } = profile;
const isOwnProfile = auth.isAuthenticated && auth.user._id === userId;
return (
<div className=\"container mt-5\">
<div className=\"row\">
<div className=\"col-md-4 text-center\">
<img
src={avatar || \"/logo512.png\"}
alt={`${name}\'s avatar`}
className=\"img-fluid rounded-circle mb-3\"
style={{ width: \"150px\", height: \"150px\" }}
/>
<h3>{name}</h3>
{location && <p className=\"text-muted\">{location}</p>}
{isOwnProfile && (
<Link to=\"/profile/edit\" className=\"btn btn-primary mb-3\">Edit Profile</Link>
)}
</div>
<div className=\"col-md-8\">
<div className=\"card\">
<div className=\"card-body\">