From 0ca36b69ebb4d25e218fdf0eb51573bd904c6ab3 Mon Sep 17 00:00:00 2001 From: chark1es Date: Fri, 28 Mar 2025 01:49:02 -0700 Subject: [PATCH] add leaderboard --- .../dashboard/LeaderboardSection.astro | 34 ++ .../LeaderboardSection/LeaderboardStats.tsx | 171 +++++++++ .../LeaderboardSection/LeaderboardTable.tsx | 334 ++++++++++++++++++ src/config/dashboard.yaml | 9 +- src/scripts/pocketbase/README.md | 145 ++++++++ src/scripts/pocketbase/Realtime.ts | 196 ++++++++++ src/scripts/pocketbase/index.ts | 14 + 7 files changed, 902 insertions(+), 1 deletion(-) create mode 100644 src/components/dashboard/LeaderboardSection.astro create mode 100644 src/components/dashboard/LeaderboardSection/LeaderboardStats.tsx create mode 100644 src/components/dashboard/LeaderboardSection/LeaderboardTable.tsx create mode 100644 src/scripts/pocketbase/README.md create mode 100644 src/scripts/pocketbase/Realtime.ts create mode 100644 src/scripts/pocketbase/index.ts diff --git a/src/components/dashboard/LeaderboardSection.astro b/src/components/dashboard/LeaderboardSection.astro new file mode 100644 index 0000000..35dd917 --- /dev/null +++ b/src/components/dashboard/LeaderboardSection.astro @@ -0,0 +1,34 @@ +--- +import LeaderboardTable from "./LeaderboardSection/LeaderboardTable"; +import LeaderboardStats from "./LeaderboardSection/LeaderboardStats"; +--- + +
+
+
+

+ + + + Leaderboard +

+
+ + +
+ +
+ + + +
+
+
diff --git a/src/components/dashboard/LeaderboardSection/LeaderboardStats.tsx b/src/components/dashboard/LeaderboardSection/LeaderboardStats.tsx new file mode 100644 index 0000000..9291492 --- /dev/null +++ b/src/components/dashboard/LeaderboardSection/LeaderboardStats.tsx @@ -0,0 +1,171 @@ +import { useState, useEffect } from 'react'; +import { Get } from '../../../scripts/pocketbase/Get'; +import { Authentication } from '../../../scripts/pocketbase/Authentication'; + +interface LeaderboardStats { + totalUsers: number; + totalPoints: number; + topScore: number; + yourPoints: number; + yourRank: number | null; +} + +export default function LeaderboardStats() { + const [stats, setStats] = useState({ + totalUsers: 0, + totalPoints: 0, + topScore: 0, + yourPoints: 0, + yourRank: null + }); + const [loading, setLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [currentUserId, setCurrentUserId] = useState(null); + + const get = Get.getInstance(); + const auth = Authentication.getInstance(); + + // Set the current user ID once on component mount + useEffect(() => { + try { + // Use the Authentication class directly + const isLoggedIn = auth.isAuthenticated(); + setIsAuthenticated(isLoggedIn); + + if (isLoggedIn) { + const user = auth.getCurrentUser(); + if (user && user.id) { + setCurrentUserId(user.id); + } else { + setLoading(false); + } + } else { + setLoading(false); + } + } catch (err) { + console.error('Error checking authentication:', err); + setLoading(false); + } + }, []); + + useEffect(() => { + const fetchStats = async () => { + try { + setLoading(true); + + // Get all users without sorting - we'll sort on client side + const response = await get.getList('limitedUser', 1, 500, '', '', { + fields: ['id', 'name', 'points'] + }); + + // Filter out users with no points for the leaderboard stats + const leaderboardUsers = response.items + .filter((user: any) => + user.points !== undefined && + user.points !== null && + user.points > 0 + ) + // Sort by points descending + .sort((a: any, b: any) => b.points - a.points); + + const totalUsers = leaderboardUsers.length; + const totalPoints = leaderboardUsers.reduce((sum: number, user: any) => sum + (user.points || 0), 0); + const topScore = leaderboardUsers.length > 0 ? leaderboardUsers[0].points : 0; + + // Find current user's points and rank - BUT don't filter by points > 0 for the current user + let yourPoints = 0; + let yourRank = null; + + if (isAuthenticated && currentUserId) { + // Look for the current user in ALL users, not just those with points > 0 + const currentUser = response.items.find((user: any) => user.id === currentUserId); + + if (currentUser) { + yourPoints = currentUser.points || 0; + + // Only calculate rank if user has points + if (yourPoints > 0) { + // Find user position in the sorted array + for (let i = 0; i < leaderboardUsers.length; i++) { + if (leaderboardUsers[i].id === currentUserId) { + yourRank = i + 1; + break; + } + } + } + } + } + + setStats({ + totalUsers, + totalPoints, + topScore, + yourPoints, + yourRank + }); + } catch (err) { + console.error('Error fetching leaderboard stats:', err); + // Set fallback stats + setStats({ + totalUsers: 0, + totalPoints: 0, + topScore: 0, + yourPoints: 0, + yourRank: null + }); + } finally { + setLoading(false); + } + }; + + fetchStats(); + }, [isAuthenticated, currentUserId]); + + if (loading) { + return ( +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ ))} +
+ ); + } + + return ( +
+
+
Total Members
+
{stats.totalUsers}
+
In the leaderboard
+
+ +
+
Total Points
+
{stats.totalPoints}
+
Earned by all members
+
+ +
+
Top Score
+
{stats.topScore}
+
Highest individual points
+
+ +
+
Your Score
+
+ {isAuthenticated ? stats.yourPoints : '-'} +
+
+ {isAuthenticated + ? (stats.yourRank ? `Ranked #${stats.yourRank}` : 'Not ranked yet') + : 'Log in to see your rank' + } +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/dashboard/LeaderboardSection/LeaderboardTable.tsx b/src/components/dashboard/LeaderboardSection/LeaderboardTable.tsx new file mode 100644 index 0000000..d505c01 --- /dev/null +++ b/src/components/dashboard/LeaderboardSection/LeaderboardTable.tsx @@ -0,0 +1,334 @@ +import { useState, useEffect } from 'react'; +import { Get } from '../../../scripts/pocketbase/Get'; +import { Authentication } from '../../../scripts/pocketbase/Authentication'; +import type { User } from '../../../schemas/pocketbase/schema'; + +interface LeaderboardUser { + id: string; + name: string; + points: number; + avatar?: string; + major?: string; +} + +// Trophy icon SVG for the rankings +const TrophyIcon = ({ className }: { className: string }) => ( + + + +); + +export default function LeaderboardTable() { + const [users, setUsers] = useState([]); + const [filteredUsers, setFilteredUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentUserRank, setCurrentUserRank] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const usersPerPage = 10; + + const [currentUserId, setCurrentUserId] = useState(null); + + const get = Get.getInstance(); + const auth = Authentication.getInstance(); + + // Set the current user ID once on component mount + useEffect(() => { + try { + // Use the Authentication class directly + const isLoggedIn = auth.isAuthenticated(); + setIsAuthenticated(isLoggedIn); + + if (isLoggedIn) { + const user = auth.getCurrentUser(); + if (user && user.id) { + setCurrentUserId(user.id); + } else { + setLoading(false); + } + } else { + setLoading(false); + } + } catch (err) { + console.error('Error checking authentication:', err); + setLoading(false); + } + }, []); + + useEffect(() => { + const fetchLeaderboard = async () => { + try { + setLoading(true); + + // Fetch users without sorting - we'll sort on client side + const response = await get.getList('limitedUser', 1, 100, '', '', { + fields: ['id', 'name', 'points', 'avatar', 'major'] + }); + + // First get the current user separately so we can include them even if they have 0 points + let currentUserData = null; + if (isAuthenticated && currentUserId) { + currentUserData = response.items.find((user: Partial) => user.id === currentUserId); + } + + // Filter and map to our leaderboard user format, and sort client-side + let leaderboardUsers = response.items + .filter((user: Partial) => user.points !== undefined && user.points !== null && user.points > 0) + .sort((a: Partial, b: Partial) => (b.points || 0) - (a.points || 0)) + .map((user: Partial, index: number) => { + // Check if this is the current user + if (isAuthenticated && user.id === currentUserId) { + setCurrentUserRank(index + 1); + } + + return { + id: user.id || '', + name: user.name || 'Anonymous User', + points: user.points || 0, + avatar: user.avatar, + major: user.major + }; + }); + + // Include current user even if they have 0 points, + // but don't include in ranking if they have no points + if (isAuthenticated && currentUserData && + !leaderboardUsers.some(user => user.id === currentUserId)) { + // User isn't already in the list (has 0 points) + leaderboardUsers.push({ + id: currentUserData.id || '', + name: currentUserData.name || 'Anonymous User', + points: currentUserData.points || 0, + avatar: currentUserData.avatar, + major: currentUserData.major + }); + } + + setUsers(leaderboardUsers); + setFilteredUsers(leaderboardUsers); + } catch (err) { + console.error('Error fetching leaderboard:', err); + setError('Failed to load leaderboard data'); + } finally { + setLoading(false); + } + }; + + fetchLeaderboard(); + }, [isAuthenticated, currentUserId]); + + useEffect(() => { + if (searchQuery.trim() === '') { + setFilteredUsers(users); + setCurrentPage(1); + return; + } + + const filtered = users.filter(user => + user.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (user.major && user.major.toLowerCase().includes(searchQuery.toLowerCase())) + ); + + setFilteredUsers(filtered); + setCurrentPage(1); + }, [searchQuery, users]); + + // Get current users for pagination + const indexOfLastUser = currentPage * usersPerPage; + const indexOfFirstUser = indexOfLastUser - usersPerPage; + const currentUsers = filteredUsers.slice(indexOfFirstUser, indexOfLastUser); + const totalPages = Math.ceil(filteredUsers.length / usersPerPage); + + const paginate = (pageNumber: number) => setCurrentPage(pageNumber); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+
+ + + + {error} +
+
+ ); + } + + if (users.length === 0) { + return ( +
+

No users with points found

+
+ ); + } + + return ( +
+ {/* Search bar */} +
+
+ + + +
+ setSearchQuery(e.target.value)} + /> +
+ + {/* Leaderboard table */} +
+ + + + + + + + + + {currentUsers.map((user, index) => { + const actualRank = user.points > 0 ? indexOfFirstUser + index + 1 : null; + const isCurrentUser = user.id === currentUserId; + + return ( + + + + + + ); + })} + +
+ Rank + + User + + Points +
+ {actualRank ? ( + actualRank <= 3 ? ( + + {actualRank === 1 && } + {actualRank === 2 && } + {actualRank === 3 && } + + ) : ( + {actualRank} + ) + ) : ( + Not Ranked + )} + +
+
+
+ {user.avatar ? ( + {user.name} + ) : ( + + {user.name.charAt(0).toUpperCase()} + + )} +
+
+
+
+ {user.name} +
+ {user.major && ( +
+ {user.major} +
+ )} +
+
+
+ {user.points} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ )} + + {/* Show current user rank if not in current page */} + {isAuthenticated && currentUserRank && !currentUsers.some(user => user.id === currentUserId) && ( +
+

+ Your rank: #{currentUserRank} +

+
+ )} + + {/* Current user with 0 points */} + {isAuthenticated && currentUserId && + !currentUserRank && + currentUsers.some(user => user.id === currentUserId) && ( +
+

+ Participate in events to earn points and get ranked! +

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/config/dashboard.yaml b/src/config/dashboard.yaml index 4cbcf14..25abdc5 100644 --- a/src/config/dashboard.yaml +++ b/src/config/dashboard.yaml @@ -14,6 +14,13 @@ sections: component: "EventsSection" class: "text-secondary hover:text-secondary-focus" + leaderboard: + title: "Leaderboard" + icon: "heroicons:trophy" + role: "none" + component: "LeaderboardSection" + class: "text-warning hover:text-warning-focus" + reimbursement: title: "Reimbursement" icon: "heroicons:credit-card" @@ -91,7 +98,7 @@ sections: categories: main: title: "Main Menu" - sections: ["profile", "events", "reimbursement"] + sections: ["profile", "events", "leaderboard", "reimbursement"] role: "none" officer: diff --git a/src/scripts/pocketbase/README.md b/src/scripts/pocketbase/README.md new file mode 100644 index 0000000..243e107 --- /dev/null +++ b/src/scripts/pocketbase/README.md @@ -0,0 +1,145 @@ +# PocketBase Integration + +This directory contains the necessary scripts for interacting with PocketBase. + +## Authentication + +The `Authentication.ts` file handles user authentication, including login, logout, and token management. + +## Data Retrieval + +The `Get.ts` file provides methods for retrieving data from PocketBase collections. + +## Data Updates + +The `Update.ts` file provides methods for updating data in PocketBase collections. + +## File Management + +The `FileManager.ts` file handles file uploads and downloads. + +## Realtime Subscriptions + +The `Realtime.ts` file provides methods for subscribing to realtime changes in PocketBase collections and records. + +### Usage + +#### Subscribe to a Collection + +Subscribe to changes in an entire collection: + +```typescript +import { Realtime } from "../scripts/pocketbase/Realtime"; +import { Collections } from "../schemas/pocketbase"; +import type { Event } from "../schemas/pocketbase/schema"; + +// Define the RealtimeEvent type for proper typing +interface RealtimeEvent { + action: "create" | "update" | "delete"; + record: T; +} + +// Get the singleton instance +const realtime = Realtime.getInstance(); + +// Subscribe to all event changes +const subscriptionId = realtime.subscribeToCollection>( + Collections.EVENTS, + (data) => { + console.log(`Event ${data.action}:`, data.record); + + // Handle different actions + switch (data.action) { + case "create": + console.log("New event created:", data.record.event_name); + break; + case "update": + console.log("Event updated:", data.record.event_name); + break; + case "delete": + console.log("Event deleted:", data.record.id); + break; + } + }, +); + +// Later, when you're done with the subscription +realtime.unsubscribe(subscriptionId); +``` + +#### Subscribe to a Specific Record + +Subscribe to changes for a specific record: + +```typescript +// Subscribe to a specific event +const eventId = "your_event_id"; +const specificEventSubscriptionId = realtime.subscribeToRecord< + RealtimeEvent +>( + Collections.EVENTS, + eventId, + (data) => { + console.log(`Specific event ${data.action}:`, data.record); + }, + { expand: "attendees" }, // Optional: expand relations +); + +// Later, when you're done with the subscription +realtime.unsubscribe(specificEventSubscriptionId); +``` + +#### Using in React Components + +```tsx +import { useEffect } from "react"; +import { Realtime } from "../scripts/pocketbase/Realtime"; +import { Collections } from "../schemas/pocketbase"; +import type { Event } from "../schemas/pocketbase/schema"; + +// Define the RealtimeEvent type for proper typing +interface RealtimeEvent { + action: "create" | "update" | "delete"; + record: T; +} + +function EventsComponent() { + useEffect(() => { + const realtime = Realtime.getInstance(); + + // Subscribe to events collection + const subscriptionId = realtime.subscribeToCollection>( + Collections.EVENTS, + (data) => { + // Handle the realtime update + console.log(`Event ${data.action}:`, data.record); + + // Update your component state here + // For example: + // if (data.action === 'create') { + // setEvents(prevEvents => [...prevEvents, data.record]); + // } + }, + ); + + // Cleanup function to unsubscribe when component unmounts + return () => { + realtime.unsubscribe(subscriptionId); + }; + }, []); // Empty dependency array means this runs once when component mounts + + return ( +
+

Events

+ {/* Your component JSX */} +
+ ); +} +``` + +#### Unsubscribe from All Subscriptions + +```typescript +// Unsubscribe from all subscriptions at once +realtime.unsubscribeAll(); +``` diff --git a/src/scripts/pocketbase/Realtime.ts b/src/scripts/pocketbase/Realtime.ts new file mode 100644 index 0000000..947e129 --- /dev/null +++ b/src/scripts/pocketbase/Realtime.ts @@ -0,0 +1,196 @@ +import { Authentication } from "./Authentication"; +import { Collections } from "../../schemas/pocketbase"; +import { Get } from "./Get"; + +// Type for subscription callbacks +type SubscriptionCallback = (data: T) => void; + +// Type for realtime event data +interface RealtimeEvent { + action: "create" | "update" | "delete"; + record: T; +} + +// Interface for subscription options +interface SubscriptionOptions { + expand?: string[] | string; +} + +export class Realtime { + private auth: Authentication; + private static instance: Realtime; + private subscriptions: Map>, options?: SubscriptionOptions }>; + private getService: Get; + + private constructor() { + this.auth = Authentication.getInstance(); + this.getService = Get.getInstance(); + this.subscriptions = new Map(); + } + + /** + * Get the singleton instance of Realtime + */ + public static getInstance(): Realtime { + if (!Realtime.instance) { + Realtime.instance = new Realtime(); + } + return Realtime.instance; + } + + /** + * Subscribe to collection changes + * @param collectionName The name of the collection to subscribe to + * @param callback Function to call when changes occur + * @param options Subscription options like expanding relations + * @returns Subscription ID + */ + public subscribeToCollection( + collectionName: string, + callback: SubscriptionCallback, + options?: SubscriptionOptions + ): string { + const subscriptionId = collectionName; + + // Register the callback + if (!this.subscriptions.has(subscriptionId)) { + this.subscriptions.set(subscriptionId, { + callbacks: new Set([callback]), + options + }); + this.initializeSubscription(collectionName); + } else { + this.subscriptions.get(subscriptionId)!.callbacks.add(callback); + } + + return subscriptionId; + } + + /** + * Subscribe to a specific record's changes + * @param collectionName The name of the collection + * @param recordId The ID of the record to subscribe to + * @param callback Function to call when changes occur + * @param options Subscription options like expanding relations + * @returns Subscription ID + */ + public subscribeToRecord( + collectionName: string, + recordId: string, + callback: SubscriptionCallback, + options?: SubscriptionOptions + ): string { + const subscriptionId = `${collectionName}/${recordId}`; + + // Register the callback + if (!this.subscriptions.has(subscriptionId)) { + this.subscriptions.set(subscriptionId, { + callbacks: new Set([callback]), + options + }); + this.initializeSubscription(collectionName, recordId); + } else { + this.subscriptions.get(subscriptionId)!.callbacks.add(callback); + } + + return subscriptionId; + } + + /** + * Unsubscribe from all or specific collection/record changes + * @param subscriptionId The subscription ID to unsubscribe from + * @param callback Optional specific callback to remove + */ + public unsubscribe(subscriptionId: string, callback?: SubscriptionCallback): void { + if (!this.subscriptions.has(subscriptionId)) return; + + if (callback) { + // Remove specific callback + this.subscriptions.get(subscriptionId)!.callbacks.delete(callback); + + // If no callbacks remain, remove the entire subscription + if (this.subscriptions.get(subscriptionId)!.callbacks.size === 0) { + this.subscriptions.delete(subscriptionId); + this.removeSubscription(subscriptionId); + } + } else { + // Remove entire subscription + this.subscriptions.delete(subscriptionId); + this.removeSubscription(subscriptionId); + } + } + + /** + * Initialize a subscription to a collection or record + * @param collectionName The name of the collection + * @param recordId Optional record ID to subscribe to + */ + private initializeSubscription(collectionName: string, recordId?: string): void { + const pb = this.auth.getPocketBase(); + const topic = recordId ? `${collectionName}/${recordId}` : collectionName; + + // Subscribe to the topic + pb.collection(collectionName).subscribe(recordId || "*", async (data) => { + const event = data as unknown as RealtimeEvent; + const subscriptionId = recordId ? `${collectionName}/${recordId}` : collectionName; + + if (!this.subscriptions.has(subscriptionId)) return; + + const subscription = this.subscriptions.get(subscriptionId)!; + const callbacks = subscription.callbacks; + + // Process the event data (convert UTC dates to local, expand relations if needed) + let processedRecord = Get.convertUTCToLocal(event.record); + + // If there are any expansion options, fetch the expanded record + if (subscription.options?.expand && recordId) { + try { + const expandOptions = { expand: subscription.options.expand }; + const expandedRecord = await this.getService.getOne(collectionName, recordId, expandOptions); + processedRecord = expandedRecord; + } catch (error) { + console.error("Error expanding record in realtime event:", error); + } + } + + // Notify all callbacks + callbacks.forEach(callback => { + callback({ + action: event.action, + record: processedRecord + }); + }); + }); + } + + /** + * Remove a subscription + * @param subscriptionId The subscription ID to remove + */ + private removeSubscription(subscriptionId: string): void { + const pb = this.auth.getPocketBase(); + const [collectionName, recordId] = subscriptionId.split('/'); + + if (collectionName) { + pb.collection(collectionName).unsubscribe(recordId || '*'); + } + } + + /** + * Unsubscribe from all subscriptions + */ + public unsubscribeAll(): void { + const pb = this.auth.getPocketBase(); + + // Unsubscribe from all topics + this.subscriptions.forEach((_, subscriptionId) => { + const [collectionName, recordId] = subscriptionId.split('/'); + if (collectionName) { + pb.collection(collectionName).unsubscribe(recordId || '*'); + } + }); + + // Clear the subscriptions map + this.subscriptions.clear(); + } +} \ No newline at end of file diff --git a/src/scripts/pocketbase/index.ts b/src/scripts/pocketbase/index.ts new file mode 100644 index 0000000..f67ffbd --- /dev/null +++ b/src/scripts/pocketbase/index.ts @@ -0,0 +1,14 @@ +/** + * PocketBase Scripts Index + * + * This file exports all the PocketBase service classes + * for easier imports throughout the codebase. + */ + +// Export all PocketBase services +export { Authentication } from "./Authentication"; +export { Get } from "./Get"; +export { Update } from "./Update"; +export { FileManager } from "./FileManager"; +export { Realtime } from "./Realtime"; +export { SendLog } from "./SendLog"; \ No newline at end of file