Driven by Performance
@@ -97,7 +105,7 @@ export const AboutModal = ({ onClose }) => {
return (
-
+
@@ -118,7 +126,7 @@ export const AboutModal = ({ onClose }) => {
-
+
Gamification & Fun
@@ -127,7 +135,7 @@ export const AboutModal = ({ onClose }) => {
-
+
Diversity & Representation
@@ -136,7 +144,7 @@ export const AboutModal = ({ onClose }) => {
-
+
Data-Driven Growth
@@ -173,4 +181,4 @@ export const AboutModal = ({ onClose }) => {
);
};
-export default AboutModal;
+export default AboutModal;
\ No newline at end of file
diff --git a/src/components/ui/ErrorBoundary.jsx b/src/components/ui/ErrorBoundary.jsx
index e286456..0f9a2f9 100644
--- a/src/components/ui/ErrorBoundary.jsx
+++ b/src/components/ui/ErrorBoundary.jsx
@@ -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) {
@@ -12,13 +13,30 @@ export class ErrorBoundary extends Component {
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 (
-
-
+ // Changed min-h-screen to min-h-[60vh] so it fits perfectly inside dashboard layouts too
+
+
+ {/* ADDED: Framer motion wrapper for entry animation */}
+
{/* Premium CSS-Animated SVG warning graphic */}
{/* Glowing backdrop rings */}
@@ -33,19 +51,31 @@ export class ErrorBoundary extends Component {
+
Something went wrong.
+
- The application encountered an unexpected error. Don't worry, your data is safe.
+ {customMessage}
+
+ {/* ADDED: Hidden technical details for developers (only shows in localhost/dev mode) */}
+ {process.env.NODE_ENV === 'development' && this.state.error && (
+
+
+ {this.state.error.toString()}
+
+
+ )}
+
-
+
);
}
@@ -54,4 +84,4 @@ export class ErrorBoundary extends Component {
}
}
-export default ErrorBoundary;
+export default ErrorBoundary;
\ No newline at end of file
diff --git a/src/components/ui/HowItWorksModal.jsx b/src/components/ui/HowItWorksModal.jsx
index 0130947..04cdeb0 100644
--- a/src/components/ui/HowItWorksModal.jsx
+++ b/src/components/ui/HowItWorksModal.jsx
@@ -2,8 +2,11 @@ import React from "react";
import { motion } from "framer-motion";
import { Link2, GitPullRequest, Terminal, Flame, Trophy, X, CheckCircle2 } from "lucide-react";
import Card from "./Card";
+import { useFocusTrap } from "../../hooks/useFocusTrap";
export const HowItWorksModal = ({ onClose }) => {
+ const modalRef = useFocusTrap(true);
+
const steps = [
{
num: "01",
@@ -84,19 +87,24 @@ export const HowItWorksModal = ({ onClose }) => {
className="absolute inset-0 bg-slate-900/40 dark:bg-slate-950/70 backdrop-blur-md"
/>
- {/* Modal Box */}
+ {/* Modal Box - ADDED ARIA */}
- {/* Close Button */}
+ {/* Close Button - ADDED aria-label */}
{/* Header */}
@@ -104,7 +112,7 @@ export const HowItWorksModal = ({ onClose }) => {
Platform Lifecycle
-
+
How RankerHub Works
@@ -118,9 +126,8 @@ export const HowItWorksModal = ({ onClose }) => {
const Icon = step.icon;
return (
- {/* Timeline Node (Icon Wrapper) */}
-
+
@@ -133,12 +140,10 @@ export const HowItWorksModal = ({ onClose }) => {
- {/* Detailed Description */}
{step.desc}
- {/* Point mapping details */}
{step.details && (
{step.details.map((detail, dIdx) => (
@@ -158,7 +163,7 @@ export const HowItWorksModal = ({ onClose }) => {
{/* Footer info banner */}
-
+
Ready to climb? Link your profile, start coding, and watch your developer standings rise!
@@ -167,4 +172,4 @@ export const HowItWorksModal = ({ onClose }) => {
);
};
-export default HowItWorksModal;
+export default HowItWorksModal;
\ No newline at end of file
diff --git a/src/components/ui/LogoutConfirmModal.jsx b/src/components/ui/LogoutConfirmModal.jsx
index 0d8b629..045f82b 100644
--- a/src/components/ui/LogoutConfirmModal.jsx
+++ b/src/components/ui/LogoutConfirmModal.jsx
@@ -2,8 +2,11 @@ import React from "react";
import { motion } from "framer-motion";
import { LogOut, AlertTriangle } from "lucide-react";
import GradientButton from "./GradientButton";
+import { useFocusTrap } from "../../hooks/useFocusTrap";
export const LogoutConfirmModal = ({ onClose, onConfirm }) => {
+ const modalRef = useFocusTrap(true);
+
return (
{/* Backdrop */}
@@ -15,8 +18,13 @@ export const LogoutConfirmModal = ({ onClose, onConfirm }) => {
className="absolute inset-0 bg-slate-950/60 backdrop-blur-md"
/>
- {/* Modal Box */}
+ {/* Modal Box - ADDED: ARIA Dialog Roles & Ref */}
{
>
{/* Warning Icon Banner */}
{/* Header */}
-
Confirm Logout
-
+
Confirm Logout
+
Are you sure you want to log out of RankerHub? You will need to sign in again to access your developer overview stats.
@@ -47,7 +55,7 @@ export const LogoutConfirmModal = ({ onClose, onConfirm }) => {
onClick={onConfirm}
className="flex-1 py-3 text-xs font-bold flex items-center justify-center gap-2 hover:shadow-[0_0_20px_rgba(239,68,68,0.2)] bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700"
>
-
+
Log Out
@@ -56,5 +64,4 @@ export const LogoutConfirmModal = ({ onClose, onConfirm }) => {
);
};
-export default LogoutConfirmModal;
-//
\ No newline at end of file
+export default LogoutConfirmModal;
\ No newline at end of file
diff --git a/src/components/ui/OfflineBanner.jsx b/src/components/ui/OfflineBanner.jsx
new file mode 100644
index 0000000..89408c1
--- /dev/null
+++ b/src/components/ui/OfflineBanner.jsx
@@ -0,0 +1,39 @@
+import React, { useState, useEffect } from 'react';
+import { WifiOff } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+
+const OfflineBanner = () => {
+ const [isOffline, setIsOffline] = useState(!navigator.onLine);
+
+ useEffect(() => {
+ const handleOffline = () => setIsOffline(true);
+ const handleOnline = () => setIsOffline(false);
+
+ window.addEventListener('offline', handleOffline);
+ window.addEventListener('online', handleOnline);
+
+ return () => {
+ window.removeEventListener('offline', handleOffline);
+ window.removeEventListener('online', handleOnline);
+ };
+ }, []);
+
+ return (
+
+ {isOffline && (
+
+
+ You are currently offline. Viewing cached data.
+
+ )}
+
+ );
+};
+
+export default OfflineBanner;
\ No newline at end of file
diff --git a/src/components/ui/Toast.jsx b/src/components/ui/Toast.jsx
index a876d0f..4590b65 100644
--- a/src/components/ui/Toast.jsx
+++ b/src/components/ui/Toast.jsx
@@ -20,6 +20,7 @@ export const Toast = ({ message, type = "success", onClose }) => {
}`}
role="status"
aria-live="polite"
+ aria-atomic="true" /* Ensures entire toast is read by screen reader */
>
{type === "success" ? "✅" : "❌"}
{message}
diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx
index edd6e77..fc23710 100644
--- a/src/context/AuthContext.jsx
+++ b/src/context/AuthContext.jsx
@@ -30,14 +30,13 @@ const checkAndUpdateStreak = async (data, docRef) => {
const now = new Date();
const lastLoginDate = data.lastLogin ? new Date(data.lastLogin) : null;
- // GUARD 1 (Client-side):
+ // GUARD 1 (Client-side):
if (lastLoginDate && lastLoginDate.toDateString() === now.toDateString()) {
return;
}
try {
await runTransaction(db, async (transaction) => {
- // Read absolute latest data from server
const userDoc = await transaction.get(docRef);
if (!userDoc.exists()) return;
@@ -45,7 +44,7 @@ const checkAndUpdateStreak = async (data, docRef) => {
const currentNow = new Date();
const latestLastLogin = latestData.lastLogin ? new Date(latestData.lastLogin) : null;
- // GUARD 2 (Server-side Atomic Check):
+ // GUARD 2 (Server-side Atomic Check):
if (latestLastLogin && latestLastLogin.toDateString() === currentNow.toDateString()) {
return;
}
@@ -57,10 +56,7 @@ const checkAndUpdateStreak = async (data, docRef) => {
const yesterday = new Date(currentNow);
yesterday.setDate(yesterday.getDate() - 1);
- const lastLoginDateStr = latestLastLogin.toDateString();
- const yesterdayStr = yesterday.toDateString();
-
- if (lastLoginDateStr === yesterdayStr) {
+ if (latestLastLogin.toDateString() === yesterday.toDateString()) {
newStreak += 1;
newStreakPoints += 10;
} else {
@@ -77,7 +73,6 @@ const checkAndUpdateStreak = async (data, docRef) => {
const newLongestStreak = Math.max(latestData.longestStreak || 0, newStreak);
- // 📝 Write atomic update
transaction.update(docRef, {
streak: newStreak,
longestStreak: newLongestStreak,
@@ -98,14 +93,9 @@ export const AuthProvider = ({ children }) => {
const [userData, setUserData] = useState(null);
const [loading, setLoading] = useState(auth ? true : false);
const [isOnboarding, setIsOnboarding] = useState(false);
- // GitHub OAuth access token stored only in memory, not persisted to storage
- // Firebase Auth handles session persistence securely via HTTP-only cookies
const [ghAccessToken, setGhAccessToken] = useState(null);
useEffect(() => {
- // If Firebase wasn't configured (app === null), `auth` will be null.
- // Avoid calling `onAuthStateChanged` with a null auth instance which
- // causes a runtime error in the browser bundle.
if (!auth) {
console.warn("Firebase auth is not initialized; auth listener skipped.");
return undefined;
@@ -113,7 +103,6 @@ export const AuthProvider = ({ children }) => {
let unsubscribeSnapshot = null;
- // Handle redirect result if user was redirected back
getRedirectResult(auth)
.then(async (result) => {
if (result) {
@@ -122,12 +111,8 @@ export const AuthProvider = ({ children }) => {
const accessToken = credential?.accessToken || null;
if (accessToken) {
setGhAccessToken(accessToken);
-
- sessionStorage.setItem(
- `gh_token_${authUser.uid}`,
- accessToken
- );
-}
+ sessionStorage.setItem(`gh_token_${authUser.uid}`, accessToken);
+ }
const additionalInfo = getAdditionalUserInfo(result);
const githubUsername = (additionalInfo?.username || authUser.displayName || "").trim();
@@ -154,26 +139,17 @@ export const AuthProvider = ({ children }) => {
lastLogin: new Date().toISOString(),
createdAt: new Date().toISOString(),
points: {
- gitRankPoints: 0,
- codingVersePoints: 0,
- streakPoints: 0,
- referralPoints: 0,
- auditorPoints: 0,
- totalPoints: 0
+ gitRankPoints: 0, codingVersePoints: 0, streakPoints: 0, referralPoints: 0, auditorPoints: 0, totalPoints: 0
},
lastAuditReward: null
};
await setDoc(userDocRef, skeletalUser);
} else {
- await setDoc(userDocRef, {
- lastLogin: new Date().toISOString(),
- }, { merge: true });
+ await setDoc(userDocRef, { lastLogin: new Date().toISOString() }, { merge: true });
}
}
})
- .catch((error) => {
- console.error("Redirect sign-in resolution failure:", error);
- });
+ .catch((error) => console.error("Redirect sign-in resolution failure:", error));
const unsubscribeAuth = onAuthStateChanged(auth, async (currentUser) => {
if (unsubscribeSnapshot) {
@@ -183,13 +159,8 @@ export const AuthProvider = ({ children }) => {
if (currentUser) {
setUser(currentUser);
- // Token is only available during the current session in memory
- // It will be null on page refresh, requiring fresh authentication
- // This is the secure default behavior
-
const userDocRef = doc(db, "users", currentUser.uid);
- // Try to load from cache first to reduce initial load time
const docPath = `users/${currentUser.uid}`;
const cachedData = userDataCache.get(docPath);
if (cachedData) {
@@ -198,22 +169,17 @@ export const AuthProvider = ({ children }) => {
setLoading(false);
}
- // Subscribe to real-time updates with debouncing to reduce re-renders
unsubscribeSnapshot = onSnapshot(userDocRef, (docSnap) => {
if (docSnap.exists()) {
const data = docSnap.data();
-
- // Update cache immediately for fast subsequent reads
userDataCache.set(docPath, data);
- // Debounce state updates to prevent excessive re-renders from rapid changes
listenerOptimizer.debounce(currentUser.uid, (userData) => {
setUserData(userData);
setIsOnboarding(userData.onboardingStatus === "incomplete");
setLoading(false);
}, data);
- // Update streak asynchronously
checkAndUpdateStreak(data, userDocRef);
} else {
userDataCache.delete(docPath);
@@ -223,9 +189,7 @@ export const AuthProvider = ({ children }) => {
setLoading(false);
}, null);
}
- }, (_error) => {
- setLoading(false);
- });
+ }, () => setLoading(false));
} else {
setUser(null);
@@ -245,12 +209,9 @@ export const AuthProvider = ({ children }) => {
try {
const response = await signInWithGitHub(requestRepoScope);
setLoading(true);
- if (!response) {
- // Fallback redirect flow triggered, page will unload shortly
- return null;
- }
- const { user: authUser, accessToken, result } = response;
+ if (!response) return null;
+ const { user: authUser, accessToken, result } = response;
const additionalInfo = getAdditionalUserInfo(result);
const rawUserData = {
githubUsername: additionalInfo?.username || authUser.displayName || "",
@@ -259,30 +220,18 @@ export const AuthProvider = ({ children }) => {
avatar: additionalInfo?.profile?.avatar_url || authUser.photoURL || ""
};
- // Validate and sanitize all user inputs to prevent XSS and data corruption
const validation = validateUserData(rawUserData);
- if (!validation.isValid) {
- console.warn("User data validation warnings:", validation.errors);
- }
-
const sanitizedUserData = validation.sanitized;
const githubId = additionalInfo?.profile?.id || null;
- // Store token for authenticated GitHub API requests
setGhAccessToken(accessToken);
-
- sessionStorage.setItem(
- `gh_token_${authUser.uid}`,
- accessToken
- );
+ sessionStorage.setItem(`gh_token_${authUser.uid}`, accessToken);
const userDocRef = doc(db, "users", authUser.uid);
const docSnap = await getDoc(userDocRef);
-
const today = new Date();
if (!docSnap.exists()) {
- // First login ever — initialize user document with base streak
const skeletalUser = {
uid: authUser.uid,
githubUsername: sanitizedUserData.githubUsername,
@@ -292,40 +241,20 @@ export const AuthProvider = ({ children }) => {
avatar: sanitizedUserData.avatar,
onboardingStatus: "incomplete",
privateRepoSyncEnabled: requestRepoScope,
- city: "",
- streak: 1,
- longestStreak: 0,
- githubStreak: 0,
- lastLogin: today.toISOString(),
- createdAt: today.toISOString(),
- points: {
- gitRankPoints: 0,
- codingVersePoints: 0,
- streakPoints: 10, // Base points for 1st day streak
- referralPoints: 0,
- auditorPoints: 0,
- totalPoints: 10
- },
- hubCoins: 500,
- inventory: ["oliver"],
- activeMascot: "oliver",
- lastAuditReward: null
+ city: "", streak: 1, longestStreak: 0, githubStreak: 0,
+ lastLogin: today.toISOString(), createdAt: today.toISOString(),
+ points: { gitRankPoints: 0, codingVersePoints: 0, streakPoints: 10, referralPoints: 0, auditorPoints: 0, totalPoints: 10 },
+ hubCoins: 500, inventory: ["oliver"], activeMascot: "oliver", lastAuditReward: null
};
await setDoc(userDocRef, skeletalUser);
} else {
- // Existing user: only update lastLogin and repo scope here.
- // Streak calculation is handled exclusively by checkAndUpdateStreak()
- // via an atomic runTransaction triggered by the onSnapshot listener.
- // Duplicating streak logic here caused double streak points on login day.
await setDoc(userDocRef, {
lastLogin: today.toISOString(),
...(requestRepoScope && { privateRepoSyncEnabled: true })
}, { merge: true });
}
-
return authUser;
} catch (error) {
- console.error("Login service failure:", error);
setLoading(false);
throw error;
}
@@ -334,12 +263,8 @@ export const AuthProvider = ({ children }) => {
const logout = async () => {
setLoading(true);
try {
- // No need to remove from storage since token is only in memory
- // Firebase Auth session will be cleared by signOutUser()
await signOutUser();
- if (user?.uid) {
- sessionStorage.removeItem(`gh_token_${user.uid}`);
- }
+ if (user?.uid) sessionStorage.removeItem(`gh_token_${user.uid}`);
setUser(null);
setUserData(null);
setIsOnboarding(false);
@@ -354,147 +279,97 @@ export const AuthProvider = ({ children }) => {
const purchaseMascot = async (mascotId, price) => {
if (!user || !userData) throw new Error("Not authenticated");
const currentCoins = userData.hubCoins ?? 500;
- if (currentCoins < price) {
- throw new Error("Insufficient HubCoins");
- }
+ if (currentCoins < price) throw new Error("Insufficient HubCoins");
const currentInventory = userData.inventory || ["oliver"];
- if (currentInventory.includes(mascotId)) {
- throw new Error("Mascot already owned");
- }
+ if (currentInventory.includes(mascotId)) throw new Error("Mascot already owned");
- try {
- const userRef = doc(db, "users", user.uid);
- await updateDoc(userRef, {
- hubCoins: currentCoins - price,
- inventory: [...currentInventory, mascotId],
- updatedAt: new Date().toISOString()
- });
- console.log(`Purchased mascot ${mascotId}`);
- } catch (err) {
- console.error("Failed to purchase mascot:", err);
- throw err;
- }
+ const userRef = doc(db, "users", user.uid);
+ await updateDoc(userRef, {
+ hubCoins: currentCoins - price,
+ inventory: [...currentInventory, mascotId],
+ updatedAt: new Date().toISOString()
+ });
};
const equipMascot = async (mascotId) => {
if (!user || !userData) throw new Error("Not authenticated");
const currentInventory = userData.inventory || ["oliver"];
- if (!currentInventory.includes(mascotId)) {
- throw new Error("Mascot not owned");
- }
+ if (!currentInventory.includes(mascotId)) throw new Error("Mascot not owned");
- try {
- const userRef = doc(db, "users", user.uid);
- await updateDoc(userRef, {
- activeMascot: mascotId,
- updatedAt: new Date().toISOString()
- });
- console.log(`Equipped mascot ${mascotId}`);
- } catch (err) {
- console.error("Failed to equip mascot:", err);
- throw err;
- }
+ const userRef = doc(db, "users", user.uid);
+ await updateDoc(userRef, {
+ activeMascot: mascotId,
+ updatedAt: new Date().toISOString()
+ });
};
+ // =========================================================================
+ // ISSUE #441: Web Worker Integration for Offloading Heavy GitHub Iterations
+ // =========================================================================
const fetchGitHubStats = async (uid, username) => {
- // Validate GitHub username per GitHub's rules: 1-39 characters,
- // alphanumeric + hyphens only, no leading/trailing hyphens.
- if (!username || typeof username !== "string") {
- throw new Error("GitHub username is required and must be a string.");
- }
-
+ if (!username || typeof username !== "string") throw new Error("GitHub username is required.");
const trimmedUsername = username.trim();
- if (trimmedUsername.length === 0 || trimmedUsername.length > 39) {
- throw new Error("GitHub username must be between 1 and 39 characters.");
- }
-
if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(trimmedUsername)) {
- throw new Error("GitHub username can only contain letters, digits, and hyphens, and cannot start or end with a hyphen.");
+ throw new Error("Invalid GitHub username format.");
}
const encodedUsername = encodeURIComponent(trimmedUsername);
- const token = ghAccessToken;
- const headers = token ? { Authorization: `token ${token}` } : {};
+ const headers = ghAccessToken ? { Authorization: `token ${ghAccessToken}` } : {};
try {
- const profileRes = await axios.get(`https://api.github.com/users/${encodedUsername}`, { headers });
- const publicRepos = profileRes.data.public_repos || 0;
- const followers = profileRes.data.followers || 0;
-
- let stars = 0;
- let primaryLanguage = "JavaScript";
- try {
- const reposRes = await axios.get(`https://api.github.com/users/${encodedUsername}/repos?per_page=100&type=owner`, { headers });
- stars = reposRes.data.reduce((sum, r) => sum + (r.stargazers_count || 0), 0);
-
- const langCounts = {};
- reposRes.data.forEach(r => {
- if (r.language) {
- langCounts[r.language] = (langCounts[r.language] || 0) + 1;
+ // 1. Fetch raw payloads from GitHub
+ const profileRes = await axios.get(`https://api.github.com/users/${encodedUsername}`, { headers }).catch(() => ({ data: {} }));
+ const reposRes = await axios.get(`https://api.github.com/users/${encodedUsername}/repos?per_page=100&type=owner`, { headers }).catch(() => ({ data: [] }));
+ const eventsRes = await axios.get(`https://api.github.com/users/${encodedUsername}/events?per_page=100`, { headers }).catch(() => ({ data: [] }));
+
+ const profileData = profileRes.data;
+ const reposData = reposRes.data;
+ const eventsData = eventsRes.data;
+
+ // 2. Offload heavy aggregation to the Web Worker
+ const workerResult = await new Promise((resolve, reject) => {
+ // Instantiate the worker using Vite's native URL pattern
+ const worker = new Worker(new URL('../workers/gitRankCalculator.worker.js', import.meta.url), { type: 'module' });
+
+ worker.onmessage = (e) => {
+ if (e.data.status === "success") {
+ resolve(e.data.data);
+ } else {
+ reject(new Error(e.data.error));
}
- });
- const sortedLangs = Object.keys(langCounts).sort((a, b) => langCounts[b] - langCounts[a]);
- if (sortedLangs.length > 0) {
- primaryLanguage = sortedLangs[0];
- }
- } catch (err) {
- console.warn("Stars/Language retrieval warning, defaulting:", err);
- }
-
- let commits = 0;
- try {
- const commitsRes = await axios.get(`https://api.github.com/search/commits?q=author:${encodedUsername}`, { headers });
- commits = commitsRes.data.total_count || 0;
- } catch (err) {
- console.warn("Commits retrieval failed; score will be incomplete until next refresh:", err);
- commits = 0;
- }
+ worker.terminate(); // Prevent memory leaks
+ };
- let prs = 0;
- try {
- const prsRes = await axios.get(`https://api.github.com/search/issues?q=author:${encodedUsername}+type:pr`, { headers });
- prs = prsRes.data.total_count || 0;
- } catch (err) {
- console.warn("PRs retrieval failed; score will be incomplete until next refresh:", err);
- prs = 0;
- }
+ worker.onerror = (err) => {
+ reject(err);
+ worker.terminate(); // Cleanup on failure
+ };
- let reviews = 0;
- try {
- const reviewsRes = await axios.get(`https://api.github.com/search/issues?q=reviewed-by:${encodedUsername}`, { headers });
- reviews = reviewsRes.data.total_count || 0;
- } catch (err) {
- console.warn("Reviews retrieval failed; score will be incomplete until next refresh:", err);
- reviews = 0;
- }
+ // Post the raw data for background processing
+ worker.postMessage({
+ repos: reposData,
+ events: eventsData,
+ userProfile: profileData
+ });
+ });
+ // 3. Compute continuous GitHub streak locally (Lightweight operation)
let githubStreak = 0;
try {
- const eventsRes = await axios.get(`https://api.github.com/users/${username}/events?per_page=100`, { headers });
- const events = eventsRes.data;
-
const eventDates = new Set(
- events
+ eventsData
.filter(e => e.created_at)
.map(e => e.created_at.split('T')[0])
);
const today = new Date();
- const todayStr = today.toISOString().split('T')[0];
-
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
+
+ const todayStr = today.toISOString().split('T')[0];
const yesterdayStr = yesterday.toISOString().split('T')[0];
- let dateToCheck = new Date(today);
-
- if (eventDates.has(todayStr)) {
- // Streak is active today
- } else if (eventDates.has(yesterdayStr)) {
- dateToCheck = yesterday;
- } else {
- dateToCheck = null;
- }
+ let dateToCheck = eventDates.has(todayStr) ? today : (eventDates.has(yesterdayStr) ? yesterday : null);
if (dateToCheck) {
while (true) {
@@ -508,34 +383,23 @@ export const AuthProvider = ({ children }) => {
}
}
} catch (err) {
- console.warn("GitHub events retrieval failed for streak:", err);
+ console.warn("GitHub events retrieval failed for streak calculation:", err);
}
- const gitRankPoints = (commits * 2) + (prs * 5) + (reviews * 10) + (githubStreak * 10);
+ // 4. Combine Worker results with local calculations
+ const finalGitRankPoints = workerResult.gitRankPoints + (githubStreak * 10);
return {
- commits,
- prs,
- reviews,
- publicRepos,
- stars,
- followers,
- primaryLanguage,
+ ...workerResult.githubStats, // Includes commits, prs, reviews, repos, stars, followers, primaryLanguage
githubStreak,
- gitRankPoints
+ gitRankPoints: finalGitRankPoints
};
+
} catch (error) {
- console.error("Error executing GitHub stats fetcher snapshot:", error);
+ console.error("Error executing GitHub stats fetcher snapshot via Worker:", error);
return {
- commits: 0,
- prs: 0,
- reviews: 0,
- publicRepos: 0,
- stars: 0,
- followers: 0,
- primaryLanguage: "JavaScript",
- githubStreak: 0,
- gitRankPoints: 0
+ commits: 0, prs: 0, reviews: 0, publicRepos: 0, stars: 0, followers: 0,
+ primaryLanguage: "JavaScript", githubStreak: 0, gitRankPoints: 0
};
}
};
@@ -544,39 +408,21 @@ export const AuthProvider = ({ children }) => {
if (!user || !userData?.githubUsername) return;
if (userData.lastSync) {
- const getTimestamp = (val) => {
- if (!val) return 0;
- if (val.toMillis) return val.toMillis();
- if (val.seconds) return val.seconds * 1000;
- return new Date(val).getTime();
- };
- const lastSyncTime = getTimestamp(userData.lastSync);
- const cooldownMs = 5 * 60 * 1000; // 5 minutes
- if (Date.now() - lastSyncTime < cooldownMs) {
- console.log("Background GitHub sync skipped: Cooldown active.");
- return;
- }
+ const getTimestamp = (val) => val?.toMillis ? val.toMillis() : (val?.seconds ? val.seconds * 1000 : new Date(val).getTime());
+ if (Date.now() - getTimestamp(userData.lastSync) < 5 * 60 * 1000) return;
}
try {
const ghStats = await fetchGitHubStats(user.uid, userData.githubUsername);
const userRef = doc(db, "users", user.uid);
-
const userDoc = await getDoc(userRef);
- if (!userDoc.exists()) {
- throw new Error("User document does not exist in Firestore!");
- }
- const liveData = userDoc.data();
- const currentReferralPoints = liveData.points?.referralPoints || 0;
- const currentCodingVersePoints = liveData.points?.codingVersePoints || 0;
- const currentStreakPoints = liveData.points?.streakPoints || 0;
+ if (!userDoc.exists()) throw new Error("User document does not exist in Firestore!");
- const newGitRankPoints = ghStats.gitRankPoints;
- const newTotalPoints = newGitRankPoints + currentReferralPoints + currentCodingVersePoints + currentStreakPoints;
+ const liveData = userDoc.data();
+ const newTotalPoints = ghStats.gitRankPoints + (liveData.points?.referralPoints || 0) + (liveData.points?.codingVersePoints || 0) + (liveData.points?.streakPoints || 0);
const batch = writeBatch(db);
-
batch.update(userRef, {
"githubStats.commits": ghStats.commits,
"githubStats.prs": ghStats.prs,
@@ -586,14 +432,12 @@ export const AuthProvider = ({ children }) => {
"githubStats.followers": ghStats.followers,
"githubStats.primaryLanguage": ghStats.primaryLanguage,
"githubStreak": ghStats.githubStreak,
- "points.gitRankPoints": newGitRankPoints,
+ "points.gitRankPoints": ghStats.gitRankPoints,
"points.totalPoints": newTotalPoints,
"lastSync": serverTimestamp()
});
await batch.commit();
-
- console.log("Background GitHub sync completed successfully via atomic batch.");
} catch (error) {
console.error("Background GitHub sync failed:", error);
}
@@ -604,4 +448,4 @@ export const AuthProvider = ({ children }) => {
{children}
);
-};
+};
\ No newline at end of file
diff --git a/src/context/FirestoreCacheContext.jsx b/src/context/FirestoreCacheContext.jsx
new file mode 100644
index 0000000..48d0286
--- /dev/null
+++ b/src/context/FirestoreCacheContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+};
+
+export const useFirestoreCache = (cacheKey, fetcherFn, ttl = 60000) => {
+ 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
+
+ // Function to manually invalidate cache (e.g., after a mutation)
+ const invalidateCache = () => {
+ cache.current.delete(cacheKey);
+ };
+
+ return { data, loading, error, invalidateCache };
+};
\ No newline at end of file
diff --git a/src/hooks/useFocusTrap.jsx b/src/hooks/useFocusTrap.jsx
new file mode 100644
index 0000000..a2013c5
--- /dev/null
+++ b/src/hooks/useFocusTrap.jsx
@@ -0,0 +1,49 @@
+import { useEffect, useRef } from 'react';
+
+export const useFocusTrap = (isActive) => {
+ const modalRef = useRef(null);
+
+ useEffect(() => {
+ if (!isActive || !modalRef.current) return;
+
+ const focusableElements = modalRef.current.querySelectorAll(
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
+ );
+
+ if (focusableElements.length === 0) return;
+
+ const firstElement = focusableElements[0];
+ const lastElement = focusableElements[focusableElements.length - 1];
+
+ const handleKeyDown = (e) => {
+ if (e.key !== 'Tab') return;
+
+ if (e.shiftKey) {
+ // Shift + Tab
+ if (document.activeElement === firstElement) {
+ e.preventDefault();
+ lastElement.focus();
+ }
+ } else {
+ // Tab
+ if (document.activeElement === lastElement) {
+ e.preventDefault();
+ firstElement.focus();
+ }
+ }
+ };
+
+ // Auto-focus first element when modal opens
+ firstElement.focus();
+
+ modalRef.current.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ if (modalRef.current) {
+ modalRef.current.removeEventListener('keydown', handleKeyDown);
+ }
+ };
+ }, [isActive]);
+
+ return modalRef;
+};
\ No newline at end of file
diff --git a/src/main.jsx b/src/main.jsx
index b9a1a6d..6462d3e 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,10 +1,18 @@
-import { StrictMode } from 'react'
+import React, { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
+import ReactDOM from 'react-dom'
import './index.css'
import App from './App.jsx'
+// Axe-Core for Accessibility Auditing (Runs only in DEV mode)
+if (import.meta.env.DEV) {
+ import('@axe-core/react').then((axe) => {
+ axe.default(React, ReactDOM, 1000);
+ });
+}
+
createRoot(document.getElementById('root')).render(
,
-)
+)
\ No newline at end of file
diff --git a/src/pages/Achievements.jsx b/src/pages/Achievements.jsx
index 0033101..f8c2cf9 100644
--- a/src/pages/Achievements.jsx
+++ b/src/pages/Achievements.jsx
@@ -16,21 +16,13 @@ import { systemBadges } from "../constants";
import { useAuth } from "../context/AuthContext";
const Twitter = ({ className }) => (
-
@@ -200,7 +256,6 @@ export const CodingOwl = () => {
{/* Mascot bubble */}
- {/* Mascot representation */}
{activeMascotInfo.icon}
@@ -236,7 +291,13 @@ export const CodingOwl = () => {
{/* Timer visualization */}
-
+
{formatTime(timeLeft)}
@@ -391,9 +452,9 @@ export const CodingOwl = () => {
{mascotsData.map((mascot) => {
- const isOwned = inventory.includes(mascot.id);
- const isEquipped = activeMascotId === mascot.id;
- const canAfford = hubCoins >= mascot.price;
+ const isOwned = optimisticInventory.includes(mascot.id);
+ const isEquipped = optimisticEquipped === mascot.id;
+ const canAfford = optimisticCoins >= mascot.price;
return (
@@ -423,7 +484,6 @@ export const CodingOwl = () => {
) : isOwned ? (