3e4c730860
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>
264 lines
7.6 KiB
JavaScript
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;
|