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:
@@ -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;
|
||||
Reference in New Issue
Block a user