diff --git a/next.config.mjs b/next.config.mjs
index 623ae5d8..e64ec997 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -140,6 +140,10 @@ const nextConfig = {
protocol: "https",
hostname: "github.githubassets.com",
},
+ {
+ protocol: "https",
+ hostname: "via.placeholder.com",
+ },
],
},
async headers() {
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index aac71d79..69d9c687 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -1,4 +1,4 @@
-import LazyWidget from "@/components/LazyWidget";
+import LazyWidget from "@/components/LazyWidget";
import DiscussionsWidget from "@/components/DiscussionsWidget";
import CommunityMetrics from "@/components/CommunityMetrics";
import GoalTracker from "@/components/GoalTracker";
@@ -22,6 +22,7 @@ import PersonalRecords from "@/components/PersonalRecords";
import LocalCodingTime from "@/components/LocalCodingTime";
import CodingTimeWidget from "@/components/CodingTimeWidget";
import RecentActivity from "@/components/RecentActivity";
+import FriendComparison from "@/components/FriendComparison";
import { authOptions } from "@/lib/auth";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
@@ -58,11 +59,6 @@ const CodingActivityInsightsCard = dynamic(
{ ssr: false, loading: () => },
);
-const FriendComparison = dynamic(
- () => import("@/components/FriendComparison"),
- { ssr: false, loading: () => },
-);
-
const ActivityRingChart = dynamic(
() => import("@/components/ActivityRingChart"),
{ ssr: false, loading: () => },
@@ -108,24 +104,29 @@ export default async function DashboardPage() {
{/* Quick actions */}
-
- {/* Left side actions */}
-
-
- Year in Code
-
-
- Settings
-
-
- {/* Right side exports */}
-
+
+
+ ✨ Year in Code
+
+
+
+ Compare Friends
+
+
+ Settings
+
+
@@ -198,8 +199,60 @@ export default async function DashboardPage() {
Analytics & Repositories
- {/* Repo Analytics Explorer spans full width */}
-
+ {/* Right: streak + coding time */}
+
+
+
+
+
+
+ {/* Repo analytics explorer — full width */}
+
+ }>
+
+
+
+
+ {/* -- Row 2: PR metrics + Community metrics -- */}
+
+
+ {/* PR breakdown + commit time — 2-col so charts have room */}
+
+
+ {/* Activity ring — full width */}
+
+
+ {/* Coding activity insights — full width */}
+
+ }>
+
+
+
+
+ {/* PR review trend — full width */}
+
+
+ {/* -- Row 3: Issues (2/3) + CI analytics (1/3) -- */}
+
+
}>
@@ -231,6 +284,7 @@ export default async function DashboardPage() {
+
{/* 4. GOALS & INSIGHTS */}
diff --git a/src/app/friend-compare/page.tsx b/src/app/friend-compare/page.tsx
new file mode 100644
index 00000000..b44d4c7d
--- /dev/null
+++ b/src/app/friend-compare/page.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useSession } from "next-auth/react";
+import { redirect } from "next/navigation";
+import FriendComparison from "@/components/FriendComparison";
+import dynamic from "next/dynamic";
+import Link from "next/link";
+
+const ContributionGraph = dynamic(
+ () => import("@/components/ContributionGraph"),
+ { ssr: false }
+);
+
+export default function FriendComparePage() {
+ const { data: session, status } = useSession();
+ const [showCommitActivity, setShowCommitActivity] = useState(false);
+ const [compareUsername, setCompareUsername] = useState
(null);
+
+ useEffect(() => {
+ if (status === "unauthenticated") {
+ redirect("/");
+ }
+ }, [status]);
+
+ useEffect(() => {
+ const handleShowCommitActivity = (e: Event) => {
+ const customEvent = e as CustomEvent<{ username?: string }>;
+ const username = customEvent.detail?.username;
+ setCompareUsername(username || null);
+ setShowCommitActivity(true);
+ };
+
+ const handleClearCommitActivity = () => {
+ setShowCommitActivity(false);
+ setCompareUsername(null);
+ };
+
+ window.addEventListener("devtrack:show-commit-activity", handleShowCommitActivity as EventListener);
+ window.addEventListener("devtrack:clear-compare-user", handleClearCommitActivity);
+ return () => {
+ window.removeEventListener("devtrack:show-commit-activity", handleShowCommitActivity as EventListener);
+ window.removeEventListener("devtrack:clear-compare-user", handleClearCommitActivity);
+ };
+ }, []);
+
+ // When showCommitActivity becomes true, dispatch the compare event after a tick
+ useEffect(() => {
+ if (showCommitActivity && compareUsername) {
+ // Dispatch after the component has fully mounted (1000ms delay ensures dynamic import + listener setup)
+ const timer = setTimeout(() => {
+ window.dispatchEvent(
+ new CustomEvent("devtrack:compare-user", {
+ detail: { username: compareUsername },
+ })
+ );
+ // Scroll to the element
+ const element = document.getElementById("contribution-activity");
+ if (element) {
+ const elementPosition = element.getBoundingClientRect().top;
+ const offsetPosition = elementPosition + window.pageYOffset - 100;
+ window.scrollTo({ top: offsetPosition, behavior: "smooth" });
+ }
+ }, 1000);
+ return () => clearTimeout(timer);
+ }
+ }, [showCommitActivity, compareUsername]);
+
+ // Auto-show commit activity if a friend was persisted on page refresh
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ try {
+ const persistedFriend = localStorage.getItem("devtrack:compare_username");
+ if (persistedFriend) {
+ setCompareUsername(persistedFriend);
+ setShowCommitActivity(true);
+ }
+ } catch {
+ // Silently fail if localStorage is not available
+ }
+ }
+ }, []);
+
+ if (status === "loading") {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Dashboard
+
+
+ Friend Comparison
+
+
+ Compare your GitHub stats with friends and see how you stack up
+
+
+
+ {/* Main content */}
+
+
+
+ {/* Commit Activity Comparison - Only rendered when button is clicked */}
+ {showCommitActivity && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/FriendComparison.tsx b/src/components/FriendComparison.tsx
index 05cd81c8..516ab8f5 100644
--- a/src/components/FriendComparison.tsx
+++ b/src/components/FriendComparison.tsx
@@ -29,6 +29,7 @@ export default function FriendComparison() {
if (typeof window === "undefined") return "";
return localStorage.getItem(STORAGE_KEY) ?? "";
});
+ const [selectedUserAvatar, setSelectedUserAvatar] = useState("");
const [comparingUser, setComparingUser] = useState("");
const [myData, setMyData] = useState(null);
const [friendData, setFriendData] = useState(null);
@@ -127,6 +128,7 @@ export default function FriendComparison() {
const chooseSuggestion = (user: SuggestedUser) => {
setFriendUsername(user.username);
+ setSelectedUserAvatar(user.avatarUrl);
setSuppressNextSuggestFetch(true);
setSuggestions([]);
setSuggestOpen(false);
@@ -158,6 +160,11 @@ export default function FriendComparison() {
detail: { username: trimmed },
})
);
+ window.dispatchEvent(
+ new CustomEvent("devtrack:show-commit-activity", {
+ detail: { username: trimmed },
+ })
+ );
}
} catch {
setError("An error occurred");
@@ -173,6 +180,7 @@ export default function FriendComparison() {
const clearComparison = () => {
setFriendUsername("");
+ setSelectedUserAvatar("");
setComparingUser("");
setFriendData(null);
setError("");
@@ -185,12 +193,6 @@ export default function FriendComparison() {
const handleCommitActivityClick = (e: React.MouseEvent) => {
e.preventDefault();
- const element = document.getElementById("contribution-activity");
- if (element) {
- const elementPosition = element.getBoundingClientRect().top;
- const offsetPosition = elementPosition + window.pageYOffset - 100;
- window.scrollTo({ top: offsetPosition, behavior: "smooth" });
- }
};
return (
@@ -211,39 +213,63 @@ export default function FriendComparison() {
className="flex flex-col sm:flex-row gap-2 w-full"
>
-
setFriendUsername(e.target.value)}
- onFocus={() => {
- if (suggestions.length > 0) setSuggestOpen(true);
- }}
- onKeyDown={(e) => {
- if (!suggestOpen || suggestions.length === 0) return;
-
- if (e.key === "ArrowDown") {
- e.preventDefault();
- setActiveIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
- } else if (e.key === "ArrowUp") {
- e.preventDefault();
- setActiveIndex((prev) => Math.max(prev - 1, 0));
- } else if (e.key === "Enter") {
- if (activeIndex >= 0 && activeIndex < suggestions.length) {
+ {selectedUserAvatar && friendUsername ? (
+
+
+ {friendUsername}
+
+
+ ) : (
+
setFriendUsername(e.target.value)}
+ onFocus={() => {
+ if (suggestions.length > 0) setSuggestOpen(true);
+ }}
+ onKeyDown={(e) => {
+ if (!suggestOpen || suggestions.length === 0) return;
+
+ if (e.key === "ArrowDown") {
e.preventDefault();
- chooseSuggestion(suggestions[activeIndex]);
+ setActiveIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setActiveIndex((prev) => Math.max(prev - 1, 0));
+ } else if (e.key === "Enter") {
+ if (activeIndex >= 0 && activeIndex < suggestions.length) {
+ e.preventDefault();
+ chooseSuggestion(suggestions[activeIndex]);
+ }
+ } else if (e.key === "Escape") {
+ setSuggestOpen(false);
+ setActiveIndex(-1);
}
- } else if (e.key === "Escape") {
- setSuggestOpen(false);
- setActiveIndex(-1);
- }
- }}
- aria-autocomplete="list"
- aria-expanded={suggestOpen}
- aria-controls="friend-compare-suggestions"
- className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm outline-none focus:border-[var(--accent)]"
- />
+ }}
+ aria-autocomplete="list"
+ aria-expanded={suggestOpen}
+ aria-controls="friend-compare-suggestions"
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm outline-none focus:border-[var(--accent)]"
+ />
+ )}
{suggestOpen && suggestions.length > 0 && (
{loading ? "Loading..." : "Compare"}
+
+ {friendData && (
+
+ )}
@@ -332,14 +368,41 @@ export default function FriendComparison() {
)}
-
-
-
You ({myData.username})
-
Metric
-
Them ({friendData.username})
+
+ {/* Header with profile info */}
+
+ {/* Metric column header */}
+
+ Metric
+
+
+ {/* My profile header */}
+
+
+ {myData.username}
+
+
+ {/* Friend profile header */}
+
+
+ {friendData.username}
+
-
+ {/* Metrics rows */}
+
)}
@@ -425,20 +475,20 @@ function ComparisonRow({
}
return (
-
+
+
+ {label}
+
{myValue}
{suffix}
-
- {label}
-