From 986fe8f75ceaac81dc8b049879d71d6dad35e5d0 Mon Sep 17 00:00:00 2001 From: Abhushan187 Date: Wed, 17 Jun 2026 02:26:10 +0530 Subject: [PATCH] feat: add Compare with Friend side-by-side stats view (#514) --- src/__tests__/comparison.test.js | 113 +++++ .../friends/ProfileComparisonModal.jsx | 409 ++++++++++++++++++ src/hooks/useComparison.js | 86 ++++ src/pages/Profile.jsx | 52 ++- src/services/comparisonExporter.js | 71 +++ src/services/comparisonService.js | 25 ++ 6 files changed, 735 insertions(+), 21 deletions(-) create mode 100644 src/__tests__/comparison.test.js create mode 100644 src/components/friends/ProfileComparisonModal.jsx create mode 100644 src/hooks/useComparison.js create mode 100644 src/services/comparisonExporter.js create mode 100644 src/services/comparisonService.js 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 */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search by GitHub username or RankerHub handle..." + className="w-full pl-10 pr-4 py-3 text-sm rounded-xl border border-slate-800 bg-slate-950/40 focus:outline-none focus:ring-2 focus:ring-violet-500/20 focus:border-violet-500 text-white transition-all" + /> +
+ + {loading ? "Searching..." : "Compare"} + +
+ + {(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 +
+
+

{currentUser?.name || "You"}

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

{compareUser?.name || "Friend"}

+ @{compareUser?.githubUsername || compareUser?.username || "friend"} +
+ {overallWinner === "right" && ( + + Overall Winner + + )} +
+
+ + {/* Badges Row */} +
+
= rightBadges ? "text-emerald-400" : "text-red-400"}`}> + {leftBadges} / 5 + Badges +
+
+ + Achievements +
+
= 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