Files
adopt-a-street/frontend/src/components/Leaderboard.js
T
William Valentin 3e4c730860 feat: implement comprehensive gamification, analytics, and leaderboard system
This commit adds a complete gamification system with analytics dashboards,
leaderboards, and enhanced badge tracking functionality.

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

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

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

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

🤖 Generated with OpenCode

Co-Authored-By: AI Assistant <noreply@opencode.ai>
2025-11-03 13:53:48 -08:00

264 lines
7.6 KiB
JavaScript

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;