diff --git a/src/__tests__/comparison.test.js b/src/__tests__/comparison.test.js
new file mode 100644
index 0000000..a99bf0b
--- /dev/null
+++ b/src/__tests__/comparison.test.js
@@ -0,0 +1,113 @@
+import { describe, it, expect } from "vitest";
+
+const determineWinner = (aVal, bVal, lowerIsBetter) => {
+ if (aVal === bVal) return "tie";
+ if (lowerIsBetter) return aVal < bVal ? "left" : "right";
+ return aVal > bVal ? "left" : "right";
+};
+
+const getWinnerScore = (user) => {
+ return (user?.points?.gitRankPoints || 0) * 0.5 +
+ (user?.points?.totalPoints || 0) * 0.3 +
+ (user?.githubStats?.commits || 0) * 0.1 +
+ (user?.streak || 0) * 0.1;
+};
+
+const countUnlockedBadges = (user) => {
+ if (!user) return 0;
+ const gitRankPoints = user.points?.gitRankPoints || 0;
+ const streak = user.streak || 0;
+ const codingVersePoints = user.points?.codingVersePoints || 0;
+ const referralPoints = user.points?.referralPoints || 0;
+
+ let count = 1;
+ if (gitRankPoints >= 100) count++;
+ if (streak >= 10) count++;
+ if (codingVersePoints >= 100) count++;
+ if (referralPoints >= 1000) count++;
+ return count;
+};
+
+describe("Comparison Winner Logic", () => {
+ it("higher value wins when lowerIsBetter is false", () => {
+ expect(determineWinner(100, 50, false)).toBe("left");
+ expect(determineWinner(50, 100, false)).toBe("right");
+ });
+
+ it("lower value wins when lowerIsBetter is true", () => {
+ expect(determineWinner(5, 10, true)).toBe("left");
+ expect(determineWinner(10, 5, true)).toBe("right");
+ });
+
+ it("returns tie for equal values", () => {
+ expect(determineWinner(100, 100, false)).toBe("tie");
+ expect(determineWinner(5, 5, true)).toBe("tie");
+ });
+
+ it("calculates overall winner score correctly", () => {
+ const user = {
+ points: { gitRankPoints: 100, totalPoints: 200 },
+ githubStats: { commits: 50 },
+ streak: 10,
+ };
+ expect(getWinnerScore(user)).toBe(116);
+ });
+
+ it("handles missing data gracefully", () => {
+ expect(getWinnerScore({})).toBe(0);
+ expect(determineWinner(0, 0, false)).toBe("tie");
+ });
+});
+
+describe("Badge Counting", () => {
+ it("always counts Pioneer badge", () => {
+ expect(countUnlockedBadges({})).toBe(1);
+ });
+
+ it("counts all unlocked badges", () => {
+ const user = {
+ points: { gitRankPoints: 150, totalPoints: 500, codingVersePoints: 200, referralPoints: 2000 },
+ streak: 15,
+ };
+ expect(countUnlockedBadges(user)).toBe(5);
+ });
+
+ it("counts partial badges", () => {
+ const user = {
+ points: { gitRankPoints: 50, totalPoints: 100, codingVersePoints: 50, referralPoints: 500 },
+ streak: 5,
+ };
+ expect(countUnlockedBadges(user)).toBe(1);
+ });
+});
+
+describe("localStorage Persistence", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("stores and retrieves recent comparisons", () => {
+ const STORAGE_KEY = "rankerhub_recent_comparisons";
+ const data = [
+ { username: "alice", name: "Alice", avatar: "https://example.com/a.png" },
+ { username: "bob", name: "Bob", avatar: "https://example.com/b.png" },
+ ];
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
+ const retrieved = JSON.parse(localStorage.getItem(STORAGE_KEY));
+ expect(retrieved).toHaveLength(2);
+ expect(retrieved[0].username).toBe("alice");
+ });
+
+ it("respects max 5 entries", () => {
+ const STORAGE_KEY = "rankerhub_recent_comparisons";
+ const data = Array.from({ length: 7 }, (_, i) => ({
+ username: `user${i}`,
+ name: `User ${i}`,
+ avatar: "",
+ }));
+ const trimmed = data.slice(0, 5);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed));
+ const retrieved = JSON.parse(localStorage.getItem(STORAGE_KEY));
+ expect(retrieved).toHaveLength(5);
+ });
+});
\ No newline at end of file
diff --git a/src/components/friends/ProfileComparisonModal.jsx b/src/components/friends/ProfileComparisonModal.jsx
new file mode 100644
index 0000000..478813c
--- /dev/null
+++ b/src/components/friends/ProfileComparisonModal.jsx
@@ -0,0 +1,409 @@
+import React, { useState, useRef, useMemo } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { X, Search, Users, Trophy, GitPullRequest, Flame, Award, Crown, Download, History, Trash2, AlertCircle, GitCommit, Star, GitFork, TrendingUp, Medal, ShieldCheck } from "lucide-react";
+import Card from "../ui/Card";
+import GradientButton from "../ui/GradientButton";
+import Toast from "../ui/Toast";
+import { exportComparisonAsPng } from "../../services/comparisonExporter";
+
+const METRIC_CONFIG = [
+ { key: "totalPoints", label: "Total XP", icon: Trophy, color: "text-amber-500" },
+ { key: "gitRankPoints", label: "GitRank Score", icon: Medal, color: "text-blue-500" },
+ { key: "commits", label: "Total Commits", icon: GitCommit, color: "text-emerald-500" },
+ { key: "streak", label: "Current Streak", icon: Flame, color: "text-orange-500" },
+ { key: "repos", label: "Repositories", icon: GitFork, color: "text-violet-500" },
+ { key: "stars", label: "Stars Earned", icon: Star, color: "text-yellow-500" },
+ { key: "followers", label: "Followers", icon: Users, color: "text-pink-500" },
+ { key: "globalRank", label: "Global Rank", icon: TrendingUp, color: "text-cyan-500", lowerIsBetter: true },
+];
+
+const getMetricValue = (user, key) => {
+ if (!user) return 0;
+ switch (key) {
+ case "totalPoints": return user.points?.totalPoints || 0;
+ case "gitRankPoints": return user.points?.gitRankPoints || 0;
+ case "commits": return user.githubStats?.commits || 0;
+ case "streak": return user.streak || 0;
+ case "repos": return user.githubStats?.repos || 0;
+ case "stars": return user.githubStats?.stars || 0;
+ case "followers": return user.githubStats?.followers || 0;
+ case "globalRank": return user.globalRank || 999999;
+ default: return 0;
+ }
+};
+
+const formatMetric = (value, key) => {
+ if (key === "globalRank") return value === 999999 ? "Unranked" : `#${value.toLocaleString()}`;
+ return value.toLocaleString();
+};
+
+const determineWinner = (aVal, bVal, lowerIsBetter) => {
+ if (aVal === bVal) return "tie";
+ if (lowerIsBetter) return aVal < bVal ? "left" : "right";
+ return aVal > bVal ? "left" : "right";
+};
+
+const getWinnerScore = (user) => {
+ return (user?.points?.gitRankPoints || 0) * 0.5 +
+ (user?.points?.totalPoints || 0) * 0.3 +
+ (user?.githubStats?.commits || 0) * 0.1 +
+ (user?.streak || 0) * 0.1;
+};
+
+const countUnlockedBadges = (user) => {
+ if (!user) return 0;
+ const gitRankPoints = user.points?.gitRankPoints || 0;
+ const streak = user.streak || 0;
+ const codingVersePoints = user.points?.codingVersePoints || 0;
+ const referralPoints = user.points?.referralPoints || 0;
+
+ let count = 1; // b1 Pioneer always unlocked
+ if (gitRankPoints >= 100) count++;
+ if (streak >= 10) count++;
+ if (codingVersePoints >= 100) count++;
+ if (referralPoints >= 1000) count++;
+ return count;
+};
+
+export const ProfileComparisonModal = ({
+ isOpen,
+ onClose,
+ currentUser,
+ compareUser,
+ loading,
+ error,
+ onSelectUser,
+ recentComparisons,
+ onClearRecent,
+}) => {
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searchError, setSearchError] = useState(null);
+ const [toasts, setToasts] = useState([]);
+ const [exporting, setExporting] = useState(false);
+ const comparisonRef = useRef(null);
+
+ const handleSearch = async (e) => {
+ e.preventDefault();
+ setSearchError(null);
+ const query = searchQuery.trim();
+ if (!query) return;
+
+ const currentUsername = currentUser?.githubUsername || currentUser?.username;
+ if (query.toLowerCase() === currentUsername?.toLowerCase()) {
+ setSearchError("You cannot compare with yourself.");
+ return;
+ }
+
+ await onSelectUser(query);
+ setSearchQuery("");
+ };
+
+ const addToast = (message, type = "success") => {
+ setToasts((prev) => [...prev, { id: Date.now() + Math.random(), message, type }]);
+ };
+
+ const handleExport = async () => {
+ if (!comparisonRef.current) return;
+ setExporting(true);
+ try {
+ const currentName = currentUser?.githubUsername || currentUser?.name || "user";
+ const compareName = compareUser?.githubUsername || compareUser?.name || "friend";
+ await exportComparisonAsPng(comparisonRef.current, `rankerhub-compare-${currentName}-vs-${compareName}.png`);
+ addToast("Comparison image downloaded!", "success");
+ } catch (err) {
+ addToast(err.message || "Failed to export image", "error");
+ } finally {
+ setExporting(false);
+ }
+ };
+
+ const overallWinner = useMemo(() => {
+ if (!compareUser) return null;
+ const leftScore = getWinnerScore(currentUser);
+ const rightScore = getWinnerScore(compareUser);
+ if (Math.abs(leftScore - rightScore) < 0.01) return "tie";
+ return leftScore > rightScore ? "left" : "right";
+ }, [currentUser, compareUser]);
+
+ const leftBadges = countUnlockedBadges(currentUser);
+ const rightBadges = countUnlockedBadges(compareUser);
+
+ const leftWins = useMemo(() => {
+ if (!compareUser) return 0;
+ let wins = 0;
+ METRIC_CONFIG.forEach((m) => {
+ const left = getMetricValue(currentUser, m.key);
+ const right = getMetricValue(compareUser, m.key);
+ const winner = determineWinner(left, right, m.lowerIsBetter);
+ if (winner === "left") wins++;
+ });
+ return wins;
+ }, [currentUser, compareUser]);
+
+ const rightWins = useMemo(() => {
+ if (!compareUser) return 0;
+ let wins = 0;
+ METRIC_CONFIG.forEach((m) => {
+ const left = getMetricValue(currentUser, m.key);
+ const right = getMetricValue(compareUser, m.key);
+ const winner = determineWinner(left, right, m.lowerIsBetter);
+ if (winner === "right") wins++;
+ });
+ return wins;
+ }, [currentUser, compareUser]);
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+ {/* Close */}
+
+
+ {/* Header */}
+
+
+
+ Social Benchmarking
+
+
+ Compare with Friend
+
+
+ Select another RankerHub developer to see a side-by-side breakdown of stats, ranks, and achievements.
+
+
+
+ {/* Search */}
+
+
+ {(error || searchError) && (
+
+
+
{error || searchError}
+
+ )}
+
+ {/* Recent Comparisons */}
+ {recentComparisons.length > 0 && !compareUser && (
+
+
+
+ Recent Comparisons
+
+
+
+
+ {recentComparisons.map((u) => (
+
+ ))}
+
+
+ )}
+
+ {/* Comparison Content */}
+ {compareUser && (
+
+ {/* Export Button */}
+
+
+
+ {exporting ? "Exporting..." : "Save as Image"}
+
+
+
+ {/* Comparison Card — export target */}
+
+ {/* Profile Headers */}
+
+ {/* Left Profile */}
+
+
+

+
+
+
{currentUser?.name || "You"}
+ @{currentUser?.githubUsername || currentUser?.username || "user"}
+
+ {overallWinner === "left" && (
+
+ Overall Winner
+
+ )}
+
+
+ {/* VS */}
+
+
VS
+
+ {leftWins}
+ —
+ {rightWins}
+
+
+
+ {/* Right Profile */}
+
+
+

+
+
+
{compareUser?.name || "Friend"}
+ @{compareUser?.githubUsername || compareUser?.username || "friend"}
+
+ {overallWinner === "right" && (
+
+ Overall Winner
+
+ )}
+
+
+
+ {/* Badges Row */}
+
+
= rightBadges ? "text-emerald-400" : "text-red-400"}`}>
+ {leftBadges} / 5
+ Badges
+
+
+
= leftBadges ? "text-emerald-400" : "text-red-400"}`}>
+ {rightBadges} / 5
+ Badges
+
+
+
+ {/* Metrics Grid */}
+
+ {METRIC_CONFIG.map((metric) => {
+ const leftVal = getMetricValue(currentUser, metric.key);
+ const rightVal = getMetricValue(compareUser, metric.key);
+ const winner = determineWinner(leftVal, rightVal, metric.lowerIsBetter);
+ const Icon = metric.icon;
+
+ return (
+
+
+ {formatMetric(leftVal, metric.key)}
+
+
+
+
+ {metric.label}
+
+
+
+ {formatMetric(rightVal, metric.key)}
+
+
+ );
+ })}
+
+
+ {/* Trust Score Row */}
+
+
= (compareUser?.points?.trustScore ?? 0) ? "text-emerald-400" : "text-red-400"}`}>
+ {currentUser?.points?.trustScore ?? "—"}
+ Trust Score
+
+
+
+ Quality
+
+
= (currentUser?.points?.trustScore ?? 0) ? "text-emerald-400" : "text-red-400"}`}>
+ {compareUser?.points?.trustScore ?? "—"}
+ Trust Score
+
+
+
+ {/* Footer */}
+
+
+ RankerHub — {new Date().toLocaleDateString()}
+
+
+
+
+ )}
+
+ {/* Toasts */}
+
+
+ {toasts.map((toast) => (
+ setToasts((prev) => prev.filter((t) => t.id !== toast.id))}
+ />
+ ))}
+
+
+
+
+ );
+};
+
+export default ProfileComparisonModal;
\ No newline at end of file
diff --git a/src/hooks/useComparison.js b/src/hooks/useComparison.js
new file mode 100644
index 0000000..ec04e0b
--- /dev/null
+++ b/src/hooks/useComparison.js
@@ -0,0 +1,86 @@
+import { useState, useCallback } from "react";
+import { fetchUserProfileByUsername } from "../services/comparisonService";
+
+const STORAGE_KEY = "rankerhub_recent_comparisons";
+const MAX_RECENT = 5;
+
+const getStoredComparisons = () => {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ return raw ? JSON.parse(raw) : [];
+ } catch {
+ return [];
+ }
+};
+
+const storeComparisons = (list) => {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(list.slice(0, MAX_RECENT)));
+ } catch {
+ // silently fail
+ }
+};
+
+export const useComparison = () => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [compareUser, setCompareUser] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [recentComparisons, setRecentComparisons] = useState(getStoredComparisons);
+
+ const openModal = useCallback(() => setIsOpen(true), []);
+ const closeModal = useCallback(() => {
+ setIsOpen(false);
+ setCompareUser(null);
+ setError(null);
+ }, []);
+
+ const selectUser = useCallback(async (username) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await fetchUserProfileByUsername(username);
+ if (!data) {
+ throw new Error("User not found");
+ }
+ setCompareUser(data);
+
+ setRecentComparisons((prev) => {
+ const filtered = prev.filter((u) => u.username !== (data.githubUsername || data.username || username));
+ const next = [
+ {
+ username: data.githubUsername || data.username || username,
+ name: data.name || username,
+ avatar: data.avatar || data.photoURL || `https://ui-avatars.com/api/?name=${data.name || username}&background=random`
+ },
+ ...filtered,
+ ];
+ storeComparisons(next);
+ return next;
+ });
+ } catch (err) {
+ setError(err.message || "Failed to load user");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const clearRecent = useCallback(() => {
+ setRecentComparisons([]);
+ storeComparisons([]);
+ }, []);
+
+ return {
+ isOpen,
+ openModal,
+ closeModal,
+ compareUser,
+ loading,
+ error,
+ selectUser,
+ recentComparisons,
+ clearRecent,
+ };
+};
+
+export default useComparison;
\ No newline at end of file
diff --git a/src/pages/Profile.jsx b/src/pages/Profile.jsx
index 4fe0193..e9b5350 100644
--- a/src/pages/Profile.jsx
+++ b/src/pages/Profile.jsx
@@ -3,27 +3,7 @@ import domtoimage from 'dom-to-image-more';
import { useParams, useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import LottiePlayer from "../components/ui/LottiePlayer";
-import {
- MapPin,
- Calendar,
- Award,
- ShieldCheck,
- Mail,
- Edit2,
- X,
- Save,
- Plus,
- User,
- Building2,
- HelpCircle,
- Search,
- Image,
- AlertCircle,
- Zap,
- Share2,
- Code,
- Copy
-} from "lucide-react";
+import { MapPin, Calendar, Award, ShieldCheck, Mail, Edit2, X, Save, Plus, User, Building2, HelpCircle, Search, Image, AlertCircle, Zap, Share2, Code, Copy, Users } from "lucide-react";
import { Github, Linkedin, Instagram } from "../components/ui/Icons";
import { query, collection, where, getCountFromServer, doc, getDoc, writeBatch, updateDoc, getDocs } from "firebase/firestore";
import { db } from "../lib/firebase";
@@ -32,6 +12,8 @@ import RankingBreakdown from "../components/dashboard/RankingBreakdown";
import successTick from "../assets/animations/succes_tick.json";
import trophyAnimation from "../assets/animations/trophy.json";
import { systemBadges } from "../constants";
+import { useComparison } from "../hooks/useComparison";
+import ProfileComparisonModal from "../components/friends/ProfileComparisonModal";
import Card from "../components/ui/Card";
import SectionHeader from "../components/ui/SectionHeader";
import Loader from "../components/ui/Loader";
@@ -102,6 +84,17 @@ export const Profile = () => {
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isEmbedModalOpen, setIsEmbedModalOpen] = useState(false);
+ const {
+ isOpen: isComparisonOpen,
+ openModal: openComparisonModal,
+ closeModal: closeComparisonModal,
+ compareUser,
+ loading: comparisonLoading,
+ error: comparisonError,
+ selectUser,
+ recentComparisons,
+ clearRecent,
+ } = useComparison();
const [editName, setEditName] = useState("");
const [editAvatar, setEditAvatar] = useState("");
const [editGender, setEditGender] = useState("");
@@ -1210,6 +1203,10 @@ if (updateData.avatar) {
Embed
+
+
+ Compare
+
@@ -2060,6 +2057,19 @@ if (updateData.avatar) {
)}
+ {/* Profile Comparison Modal */}
+
+
{/* GitHub Embed Modal */}
{isEmbedModalOpen && (
diff --git a/src/services/comparisonExporter.js b/src/services/comparisonExporter.js
new file mode 100644
index 0000000..a1d2b59
--- /dev/null
+++ b/src/services/comparisonExporter.js
@@ -0,0 +1,71 @@
+import domtoimage from 'dom-to-image-more';
+
+export const exportComparisonAsPng = async (node, filename = 'rankerhub-comparison.png') => {
+ if (!node) {
+ throw new Error("No element to export");
+ }
+
+ try {
+ if (document.fonts && document.fonts.ready) {
+ await document.fonts.ready;
+ }
+
+ const original = node;
+ const clone = original.cloneNode(true);
+
+ clone.querySelectorAll('.pointer-events-none').forEach(n => n.remove());
+
+ const copyComputedStyles = (sourceEl, targetEl) => {
+ const computed = window.getComputedStyle(sourceEl);
+ let cssText = '';
+ for (let i = 0; i < computed.length; i++) {
+ const prop = computed[i];
+ try {
+ cssText += `${prop}: ${computed.getPropertyValue(prop)}; `;
+ } catch {
+ // ignore
+ }
+ }
+ targetEl.style.cssText = cssText;
+ };
+
+ const inlineAllStyles = (srcRoot, tgtRoot) => {
+ copyComputedStyles(srcRoot, tgtRoot);
+ const srcChildren = Array.from(srcRoot.children || []);
+ const tgtChildren = Array.from(tgtRoot.children || []);
+ for (let i = 0; i < srcChildren.length; i++) {
+ if (tgtChildren[i]) inlineAllStyles(srcChildren[i], tgtChildren[i]);
+ }
+ };
+
+ try {
+ inlineAllStyles(original, clone);
+ } catch (e) {
+ console.warn('Inline styles fallback:', e);
+ }
+
+ const rect = original.getBoundingClientRect();
+ clone.style.position = 'fixed';
+ clone.style.left = '-9999px';
+ clone.style.top = '0';
+ clone.style.width = `${Math.round(rect.width)}px`;
+ clone.style.height = 'auto';
+ clone.style.boxSizing = 'border-box';
+
+ document.body.appendChild(clone);
+
+ const dataUrl = await domtoimage.toPng(clone, { cacheBust: true, bgcolor: '#0f172a' });
+
+ document.body.removeChild(clone);
+
+ const link = document.createElement('a');
+ link.download = filename;
+ link.href = dataUrl;
+ link.click();
+
+ return dataUrl;
+ } catch (err) {
+ console.error('Comparison export error:', err);
+ throw new Error("Failed to export comparison image");
+ }
+};
\ No newline at end of file
diff --git a/src/services/comparisonService.js b/src/services/comparisonService.js
new file mode 100644
index 0000000..727df2b
--- /dev/null
+++ b/src/services/comparisonService.js
@@ -0,0 +1,25 @@
+import { query, collection, where, getDocs, doc, getDoc } from "firebase/firestore";
+import { db } from "../lib/firebase";
+
+export const fetchUserProfileByUsername = async (username) => {
+ if (!username) return null;
+
+ try {
+ const q1 = query(collection(db, "users"), where("githubUsername", "==", username));
+ const snapshot1 = await getDocs(q1);
+ if (!snapshot1.empty) {
+ return snapshot1.docs[0].data();
+ }
+
+ const docRef = doc(db, "users", username);
+ const docSnap = await getDoc(docRef);
+ if (docSnap.exists()) {
+ return docSnap.data();
+ }
+
+ return null;
+ } catch (error) {
+ console.error("Error fetching comparison user:", error);
+ throw error;
+ }
+};
\ No newline at end of file