Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ service cloud.firestore {
// Locks referralPoints to the actual totalEarned value in the referrals index.
allow update: if isAuthenticated()
&& request.auth.uid != uid
// 🛡️ NEW GUARD (Issue #432): Only users actively onboarding can trigger a referral point grant
&& get(/databases/$(database)/documents/users/$(request.auth.uid)).data.onboardingStatus == "incomplete"
// Prevent modifying any root-level fields other than points
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['points'])
// FIX: Sync referralPoints directly with the referrals index document's totalEarned counter
Expand All @@ -120,7 +122,6 @@ service cloud.firestore {
&& request.resource.data.points.auditorPoints == resource.data.points.auditorPoints
&& exists(/databases/$(database)/documents/referrals/$(uid))
&& request.auth.uid in get(/databases/$(database)/documents/referrals/$(uid)).data.usedBy;
}

match /referrals/{uid} {
allow read: if isAuthenticated();
Expand All @@ -131,6 +132,8 @@ service cloud.firestore {
// Allow either the owner to write, OR a referred user to atomically append their UID
allow update: if isOwner(uid) || (
isAuthenticated()
// 🛡️ NEW GUARD (Issue #432): Prevent existing users from arbitrarily claiming codes
&& get(/databases/$(database)/documents/users/$(request.auth.uid)).data.onboardingStatus == "incomplete"
&& !(request.auth.uid in resource.data.usedBy)
&& request.resource.data.usedBy.size() == resource.data.usedBy.size() + 1
&& request.resource.data.usedBy[resource.data.usedBy.size()] == request.auth.uid
Expand Down
48 changes: 39 additions & 9 deletions src/components/ui/ErrorBoundary.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { Component } from "react";
import { motion } from "framer-motion";

export class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
this.state = { hasError: false, error: null, errorInfo: null };
}

static getDerivedStateFromError(error) {
Expand All @@ -12,13 +13,30 @@

componentDidCatch(error, errorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
this.setState({ errorInfo });
}

// ADDED: Graceful recovery method (No full page reload)
handleRetry = () => {
this.setState({ hasError: false, error: null, errorInfo: null });
};

render() {
if (this.state.hasError) {
// ADDED: Dynamic context-specific message
const customMessage = this.props.fallbackMessage || "The application encountered an unexpected error. Don't worry, your data is safe.";

return (
<div className="min-h-screen flex flex-col items-center justify-center bg-slate-50 dark:bg-slate-950 p-6 text-slate-800 dark:text-slate-100 transition-colors duration-300">
<div className="max-w-md w-full backdrop-blur-xl bg-white/70 dark:bg-slate-900/70 border border-slate-200/50 dark:border-slate-800/50 shadow-2xl rounded-2xl p-8 text-center flex flex-col items-center">
// Changed min-h-screen to min-h-[60vh] so it fits perfectly inside dashboard layouts too
<div className="w-full h-full min-h-[60vh] flex flex-col items-center justify-center p-6 text-slate-800 dark:text-slate-100 transition-colors duration-300">

{/* ADDED: Framer motion wrapper for entry animation */}
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="max-w-md w-full backdrop-blur-xl bg-white/70 dark:bg-slate-900/70 border border-slate-200/50 dark:border-slate-800/50 shadow-2xl rounded-2xl p-8 text-center flex flex-col items-center"
>
{/* Premium CSS-Animated SVG warning graphic */}
<div className="relative w-40 h-40 mb-6 flex items-center justify-center">
{/* Glowing backdrop rings */}
Expand All @@ -33,19 +51,31 @@
</svg>
</div>
</div>

<h1 className="text-2xl font-bold mb-2 text-transparent bg-clip-text bg-gradient-to-r from-violet-500 to-blue-500">
Something went wrong.
</h1>

<p className="text-sm text-slate-500 dark:text-slate-400 mb-6 font-medium">
The application encountered an unexpected error. Don't worry, your data is safe.
{customMessage}
</p>

{/* ADDED: Hidden technical details for developers (only shows in localhost/dev mode) */}
{process.env.NODE_ENV === 'development' && this.state.error && (

Check failure on line 64 in src/components/ui/ErrorBoundary.jsx

View workflow job for this annotation

GitHub Actions / Lint Check

'process' is not defined
<div className="w-full mb-6 p-3 bg-slate-50 dark:bg-slate-950 rounded-lg text-left overflow-hidden">
<p className="text-[10px] font-mono text-red-500 break-words">
{this.state.error.toString()}
</p>
</div>
)}

<button
onClick={() => window.location.reload()}
className="px-6 py-2.5 rounded-xl bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-700 hover:to-indigo-700 text-white font-semibold shadow-lg hover:shadow-indigo-500/20 active:scale-95 transition-all duration-200 cursor-pointer"
onClick={this.handleRetry}
className="px-6 py-2.5 w-full rounded-xl bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-700 hover:to-indigo-700 text-white font-semibold shadow-lg hover:shadow-indigo-500/20 active:scale-95 transition-all duration-200 cursor-pointer"
>
Refresh Page
Try Again
</button>
</div>
</motion.div>
</div>
);
}
Expand All @@ -54,4 +84,4 @@
}
}

export default ErrorBoundary;
export default ErrorBoundary;
76 changes: 76 additions & 0 deletions src/context/FirestoreCacheContext.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React, { createContext, useContext, useRef, useState, useEffect } from 'react';

const CacheContext = createContext();

export const FirestoreCacheProvider = ({ children }) => {
// Map store to hold cached data and timestamps
const cache = useRef(new Map());

return (
<CacheContext.Provider value={cache}>
{children}
</CacheContext.Provider>
);
};

export const useFirestoreCache = (cacheKey, fetcherFn, ttl = 60000) => {

Check warning on line 16 in src/context/FirestoreCacheContext.jsx

View workflow job for this annotation

GitHub Actions / Lint Check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const cache = useContext(CacheContext);
if (!cache) {
throw new Error("useFirestoreCache must be used within a FirestoreCacheProvider");
}

// Initialize with cached data if it exists
const [data, setData] = useState(() => {
const cached = cache.current.get(cacheKey);
return cached ? cached.data : null;
});

// Only show loading spinner if we have absolutely no data
const [loading, setLoading] = useState(!data);
const [error, setError] = useState(null);

useEffect(() => {
let isMounted = true;

const executeFetch = async () => {
const cached = cache.current.get(cacheKey);
const now = Date.now();

// If data exists and is within TTL, skip fetching entirely
if (cached && (now - cached.ts < ttl)) {
setLoading(false);
return;
}

try {
const freshData = await fetcherFn();
if (!isMounted) return;

// Deep Comparison Check (prevents unnecessary re-renders)
const isDataChanged = !cached || JSON.stringify(cached.data) !== JSON.stringify(freshData);

if (isDataChanged) {
setData(freshData);
cache.current.set(cacheKey, { data: freshData, ts: Date.now() });
}
} catch (err) {
if (isMounted) setError(err);
} finally {
if (isMounted) setLoading(false);
}
};

executeFetch();

return () => {
isMounted = false;
};
}, [cacheKey]); // Important: fetcherFn is omitted to prevent infinite loops

Check warning on line 68 in src/context/FirestoreCacheContext.jsx

View workflow job for this annotation

GitHub Actions / Lint Check

React Hook useEffect has missing dependencies: 'cache', 'fetcherFn', and 'ttl'. Either include them or remove the dependency array. If 'fetcherFn' changes too often, find the parent component that defines it and wrap that definition in useCallback

// Function to manually invalidate cache (e.g., after a mutation)
const invalidateCache = () => {
cache.current.delete(cacheKey);
};

return { data, loading, error, invalidateCache };
};
83 changes: 39 additions & 44 deletions src/pages/GitRank.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { useState, useEffect, useMemo } from "react";
import { Search, Filter, Star, Trophy, RefreshCw, GitCommit, Calendar, BookOpen, AlertCircle, CheckCircle2, Users, Medal } from "lucide-react";
import { collection, query, doc, where, orderBy, limit, startAfter, onSnapshot, getDocs, runTransaction, serverTimestamp } from "firebase/firestore";
import { collection, query, doc, where, orderBy, limit, startAfter, getDocs, runTransaction, serverTimestamp } from "firebase/firestore";
import { useSearchParams } from "react-router-dom";
import { TableVirtuoso } from "react-virtuoso";
import { db } from "../lib/firebase";
import { useAuth } from "../context/AuthContext";
import { useFirestoreCache } from "../context/FirestoreCacheContext";
import Card from "../components/ui/Card";
import SectionHeader from "../components/ui/SectionHeader";
import GradientButton from "../components/ui/GradientButton";
Expand All @@ -13,16 +14,10 @@ import axios from "axios";
export const GitRank = () => {
const { user, userData, fetchGitHubStats, login } = useAuth();

// ============================================================
// ISSUE #194: URL Parameter Sync for State Persistence
// ISSUE #362: Server-Side College Filter Integration
// ============================================================
const [searchParams, setSearchParams] = useSearchParams();
const searchTerm = searchParams.get("search") || "";
const selectedLanguage = searchParams.get("lang") || "All";
const selectedCollege = searchParams.get("college") || "All"; // NEW: Server-Side College State

// Active Tab for Referral Leaderboard (Issue #310) - Now synced with URL
const selectedCollege = searchParams.get("college") || "All";
const activeTab = searchParams.get("tab") || "gitrank";

const handleSearchChange = (e) => {
Expand Down Expand Up @@ -59,9 +54,8 @@ export const GitRank = () => {
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);

// Real-time leaderboard state
// Local Leaderboard state
const [usersList, setUsersList] = useState([]);
const [loadingUsers, setLoadingUsers] = useState(true);

// Syncing state
const [isSyncing, setIsSyncing] = useState(false);
Expand All @@ -77,12 +71,10 @@ export const GitRank = () => {

const languages = ["All", "TypeScript", "Rust", "Go", "Python", "Kotlin", "Ruby", "JavaScript"];

// 1. Real-time Leaderboard Listener (Server-Side Filtered)
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoadingUsers(true);
// In-Memory Navigation Cache Hook Integration
const cacheKey = `gitrank_${activeTab}_${selectedLanguage}_${selectedCollege}`;

// Build the query dynamically based on Active Tab and Tie-Breakers
const fetchLeaderboard = async () => {
const constraints = [
where("onboardingStatus", "==", "complete"),
];
Expand All @@ -96,46 +88,48 @@ export const GitRank = () => {

constraints.push(orderBy("githubUsername", "asc"));

// DB level language filter
if (selectedLanguage !== "All") {
constraints.push(where("githubStats.primaryLanguage", "==", selectedLanguage));
}

// DB level college filter (Resolves Issue #362)
if (selectedCollege !== "All" && selectedCollege.trim() !== "") {
constraints.push(where("college", "==", selectedCollege));
}

constraints.push(limit(50));

const q = query(collection(db, "users"), ...constraints);
const snapshot = await getDocs(q); // Switched from onSnapshot to getDocs to save reads

const unsubscribe = onSnapshot(
q,
(snapshot) => {
const users = [];
snapshot.forEach((doc) => {
users.push(doc.data());
});
const users = [];
snapshot.forEach((doc) => {
users.push(doc.data());
});

const ranked = users.map((u, i) => ({
...u,
rank: i + 1
}));

setUsersList(ranked);
setLastVisible(snapshot.docs[snapshot.docs.length - 1]);
setHasMore(snapshot.docs.length === 50);
setLoadingUsers(false);
},
(error) => {
console.error("Leaderboard subscription error:", error);
setLoadingUsers(false);
}
);
const ranked = users.map((u, i) => ({
...u,
rank: i + 1
}));

// Return the bundle so cache hook holds both data and pagination cursors
return {
users: ranked,
lastVisible: snapshot.docs[snapshot.docs.length - 1] || null,
hasMore: snapshot.docs.length === 50
};
};

// TTL set to 60000ms (1 minute). Modifying filters changes the key instantly.
const { data: cachedData, loading: loadingUsers, invalidateCache } = useFirestoreCache(cacheKey, fetchLeaderboard, 60000);

return () => unsubscribe();
}, [selectedLanguage, activeTab, selectedCollege]);
// Sync the cached data payload into local component state
useEffect(() => {
if (cachedData) {
setUsersList(cachedData.users);
setLastVisible(cachedData.lastVisible);
setHasMore(cachedData.hasMore);
}
}, [cachedData]);

// Pagination Function (Fetch next 50)
const loadMoreUsers = async () => {
Expand Down Expand Up @@ -200,7 +194,6 @@ export const GitRank = () => {
// 2. Fetch GitHub Events/Repos for Charts (Authenticated Only)
useEffect(() => {
if (!userData?.githubUsername) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoadingCharts(false);
return;
}
Expand Down Expand Up @@ -343,6 +336,9 @@ export const GitRank = () => {
});
});

// Clear the cache manually because our own points just updated!
invalidateCache();

setSyncSuccess("GitHub statistics updated in real time!");
setTimeout(() => setSyncSuccess(""), 4000);
} catch (err) {
Expand Down Expand Up @@ -390,7 +386,6 @@ export const GitRank = () => {
return `${mins}m ${secs}s`;
};

// Filter leaderboard lists (Only Search is client side now)
const filteredData = useMemo(() => {
return usersList.filter((u) => {
const name = u.name || "";
Expand Down Expand Up @@ -433,7 +428,7 @@ export const GitRank = () => {
return weeks;
}, [events]);

// Chart Parsing 2: Languages Frequency
// Chart Parsing 2: Languages Frequency
const languageChartData = useMemo(() => {
if (!repos.length) return [];

Expand Down
Loading
Loading