Skip to content

Commit 917f293

Browse files
authored
feat(dashboard): add GitHub Wrapped, 3D STL export, LoC mode, and Org… (JhaSourav07#954)
## Description Fixes JhaSourav07#942 This PR introduces the 4 epic dashboard enhancements as requested for GSSoC'26: 1. **GitHub Wrapped Generator 🎁**: Added an end-of-year export mode (`/dashboard/[username]/wrapped`) displaying total contributions, top language, highest daily push, busiest month, and weekend grind ratio. 2. **3D Print Export (STL) 🖨️**: Added a feature in the ShareSheet to download the 3D monolith as an STL file for 3D printing. 3. **Lines of Code (LoC) Mode 💻**: Added a toggle in the Activity Landscape to switch between Commit Count and estimated Lines of Code changed. 4. **Organization / Team Dashboards 🏢**: Created `/dashboard/org/[orgname]` route to aggregate and visualize an entire organization's contributions as a "Mega-City". ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview https://github.com/user-attachments/assets/a0e473bf-ef8e-4fa4-b3bc-900c7be8730d ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [x] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have started the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
1 parent dc2898f commit 917f293

27 files changed

Lines changed: 21986 additions & 960 deletions

app/(root)/dashboard/[username]/page.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ describe('DashboardPage', () => {
110110

111111
expect(metadata.title).toBe("octocat's Commit Pulse");
112112
expect(metadata.description).toContain("octocat's GitHub contribution pulse");
113-
expect(openGraphImage.url).toContain('api/og?username=octocat');
113+
expect(openGraphImage.url).toContain('api/og?user=octocat');
114114
expect(openGraphImage.width).toBe(1200);
115115
expect(openGraphImage.height).toBe(630);
116116
expect(openGraphImage.alt).toContain(username);
@@ -131,7 +131,7 @@ describe('DashboardPage', () => {
131131
bypassCache: false,
132132
});
133133

134-
const generateLink = screen.getByText('Generate Your Own Dashboard').closest('a');
134+
const generateLink = screen.getByText('Generate Your Own').closest('a');
135135
expect(generateLink).toBeDefined();
136136
expect(generateLink?.getAttribute('href')).toBe('/');
137137
expect(screen.getByTestId('profile-card')).toBeDefined();
@@ -160,6 +160,7 @@ describe('DashboardPage', () => {
160160
});
161161
});
162162
});
163+
163164
it('passes the correct activity data to Heatmap', async () => {
164165
const PageContent = await DashboardPage({
165166
params: Promise.resolve({ username: 'octocat' }),

app/(root)/dashboard/[username]/page.tsx

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
// app/(root)/dashboard/[username]/page.tsx
2+
13
import type { Metadata } from 'next';
4+
import { notFound, redirect } from 'next/navigation';
5+
import Link from 'next/link';
6+
27
import RefreshButton from '@/components/dashboard/RefreshButton';
38
import ProfileCard from '@/components/dashboard/ProfileCard';
49
import ActivityLandscape from '@/components/dashboard/ActivityLandscape';
@@ -8,9 +13,7 @@ import CommitClock from '@/components/dashboard/CommitClock';
813
import Heatmap from '@/components/dashboard/Heatmap';
914
import AIInsights from '@/components/dashboard/AIInsights';
1015
import Achievements from '@/components/dashboard/Achievements';
11-
import { getFullDashboardData } from '@/lib/github';
12-
import Link from 'next/link';
13-
import { notFound } from 'next/navigation';
16+
import { getFullDashboardData, fetchUserProfile } from '@/lib/github';
1417

1518
export const revalidate = 3600; // Cache for 1 hour
1619

@@ -23,10 +26,8 @@ export async function generateMetadata({
2326
}: {
2427
params: Promise<{ username: string }>;
2528
}): Promise<Metadata> {
26-
// Lightweight — no API calls here.
27-
// Real data is fetched by /api/og on demand when social platforms render the preview.
2829
const { username } = await params;
29-
const ogImage = `${BASE_URL}/api/og?username=${username}`;
30+
const ogImage = `${BASE_URL}/api/og?user=${username}`;
3031
const title = `${username}'s Commit Pulse`;
3132
const description = `Check out ${username}'s GitHub contribution pulse — streaks, insights, and more on CommitPulse.`;
3233

@@ -62,19 +63,25 @@ export default async function DashboardPage({
6263
const refreshParams = await searchParams;
6364
const bypassCache = refreshParams?.refresh === 'true';
6465

65-
// Fetch real GitHub data
6666
let data;
6767

6868
try {
69-
data = await getFullDashboardData(username, {
70-
bypassCache,
71-
});
69+
data = await getFullDashboardData(username, { bypassCache });
7270
} catch (error) {
73-
if (error instanceof Error) {
71+
if (error instanceof Error && error.message.includes('not found')) {
72+
// Smart Redirect: If the GraphQL "user" query fails, check if it's actually an Organization
73+
try {
74+
const fallbackProfile = await fetchUserProfile(username, { bypassCache });
75+
if (fallbackProfile.type === 'Organization') {
76+
redirect(`/dashboard/org/${username}`);
77+
}
78+
} catch (fallbackError) {
79+
// If it's truly neither a user nor an org, show 404
80+
return notFound();
81+
}
7482
return notFound();
7583
}
76-
77-
throw Error;
84+
throw error;
7885
}
7986

8087
return (
@@ -98,7 +105,7 @@ export default async function DashboardPage({
98105
>
99106
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
100107
</svg>
101-
Generate Your Own Dashboard
108+
Generate Your Own
102109
</Link>
103110
</div>
104111

@@ -112,9 +119,9 @@ export default async function DashboardPage({
112119
languages: data.languages,
113120
}}
114121
/>
115-
{/* We omit real achievements data generation for now and just show a placeholder based on streaks */}
116122
<Achievements achievements={data.achievements} />
117123
</aside>
124+
118125
{/* Main Content */}
119126
<div className="flex flex-col gap-6 lg:gap-8 min-w-0">
120127
<section>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { notFound } from 'next/navigation';
2+
import type { Metadata } from 'next';
3+
import GithubWrapped from '@/components/dashboard/GithubWrapped';
4+
import { getFullDashboardData, getWrappedData } from '@/lib/github';
5+
6+
export async function generateMetadata({
7+
params,
8+
}: {
9+
params: Promise<{ username: string }>;
10+
}): Promise<Metadata> {
11+
const { username } = await params;
12+
return {
13+
title: `${username}'s GitHub Wrapped`,
14+
description: `A cinematic year-in-review of ${username}'s open-source contributions.`,
15+
};
16+
}
17+
18+
export default async function WrappedPage({
19+
params,
20+
searchParams,
21+
}: {
22+
params: Promise<{ username: string }>;
23+
searchParams: Promise<{ year?: string }>;
24+
}) {
25+
const { username } = await params;
26+
const resolvedSearchParams = await searchParams;
27+
const targetYear = resolvedSearchParams?.year || new Date().getFullYear().toString();
28+
29+
// 1. Fetch data safely.
30+
// If this fails, the error will bubble up to the nearest error.tsx file.
31+
let dashboardData;
32+
let wrappedData;
33+
34+
try {
35+
[dashboardData, wrappedData] = await Promise.all([
36+
getFullDashboardData(username),
37+
getWrappedData(username, targetYear),
38+
]);
39+
} catch (error) {
40+
console.error('[Wrapped] Failed to load wrapped data:', error);
41+
// If the user doesn't exist or API fails, trigger the 404 page
42+
return notFound();
43+
}
44+
45+
// 2. Render the successful component outside of any try/catch blocks.
46+
return (
47+
<div className="min-h-[85vh] p-4 md:p-8 flex items-center justify-center relative">
48+
<GithubWrapped profile={dashboardData.profile} wrappedData={wrappedData} />
49+
</div>
50+
);
51+
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// app/(root)/dashboard/org/[orgname]/page.tsx
2+
3+
import type { Metadata } from 'next';
4+
import { notFound } from 'next/navigation';
5+
import Link from 'next/link';
6+
7+
import ProfileCard from '@/components/dashboard/ProfileCard';
8+
import ActivityLandscape from '@/components/dashboard/ActivityLandscape';
9+
import StatsCard from '@/components/dashboard/StatsCard';
10+
import CommitClock from '@/components/dashboard/CommitClock';
11+
import Heatmap from '@/components/dashboard/Heatmap';
12+
import AIInsights from '@/components/dashboard/AIInsights';
13+
import Achievements from '@/components/dashboard/Achievements';
14+
import { getOrgDashboardData, buildCommitClock, generateAchievements } from '@/lib/github';
15+
16+
export const revalidate = 3600; // Cache for 1 hour
17+
18+
const BASE_URL =
19+
process.env.NEXT_PUBLIC_SITE_URL ??
20+
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000');
21+
22+
export async function generateMetadata({
23+
params,
24+
}: {
25+
params: Promise<{ orgname: string }>;
26+
}): Promise<Metadata> {
27+
const { orgname } = await params;
28+
const ogImage = `${BASE_URL}/api/og?user=${orgname}`; // Reuse OG, but it will fetch org data gracefully
29+
const title = `${orgname} | Organization Mega-City`;
30+
const description = `Explore the aggregated open-source contribution pulse for the ${orgname} organization on CommitPulse.`;
31+
32+
return {
33+
title,
34+
description,
35+
openGraph: {
36+
title,
37+
description,
38+
url: `${BASE_URL}/dashboard/org/${orgname}`,
39+
siteName: 'CommitPulse',
40+
images: [{ url: ogImage, width: 1200, height: 630, alt: title }],
41+
type: 'profile',
42+
},
43+
};
44+
}
45+
46+
export default async function OrgDashboardPage({
47+
params,
48+
searchParams,
49+
}: {
50+
params: Promise<{ orgname: string }>;
51+
searchParams: Promise<{ refresh?: string }>;
52+
}) {
53+
const { orgname } = await params;
54+
const refreshParams = await searchParams;
55+
const bypassCache = refreshParams?.refresh === 'true';
56+
57+
let data;
58+
59+
try {
60+
data = await getOrgDashboardData(orgname, { bypassCache });
61+
} catch (error) {
62+
console.error(error);
63+
return notFound();
64+
}
65+
66+
// 1. Process Calendar into Activity Array for Org
67+
const allDays = data.calendar.weeks.flatMap((w) => w.contributionDays);
68+
const activity = allDays.map((day) => {
69+
let intensity: 0 | 1 | 2 | 3 | 4 = 0;
70+
// Scaled up intensity thresholds because orgs have way more commits than single users
71+
if (day.contributionCount > 0) intensity = 1;
72+
if (day.contributionCount > 15) intensity = 2;
73+
if (day.contributionCount > 50) intensity = 3;
74+
if (day.contributionCount > 150) intensity = 4;
75+
76+
return {
77+
date: day.date,
78+
count: day.contributionCount,
79+
intensity,
80+
};
81+
});
82+
83+
// 2. Generate Org Specific Assets
84+
const commitClock = buildCommitClock(allDays);
85+
const achievements = generateAchievements(
86+
data.stats.totalContributions,
87+
data.stats.currentStreak
88+
);
89+
90+
// 3. Custom Org AI Insights
91+
const insights = [
92+
{
93+
id: '1',
94+
icon: 'Users',
95+
text: `This organization is powered by ${data.profile.stats.following} core open-source contributors.`,
96+
},
97+
{
98+
id: '2',
99+
icon: 'GitCommit',
100+
text: `A massive ${data.stats.totalContributions} total contributions were merged by the team this year.`,
101+
},
102+
{
103+
id: '3',
104+
icon: 'Flame',
105+
text: `The team's peak collaborative streak reached ${data.stats.peakStreak} consecutive days.`,
106+
},
107+
];
108+
109+
return (
110+
<div id="dashboard-root" data-dashboard className="p-4 md:p-6 lg:p-8 min-h-screen relative">
111+
{/* Top Action Bar */}
112+
<div id="generate-dashboard-btn" className="flex justify-between items-center mb-6">
113+
<div className="inline-flex items-center gap-2 rounded-full border border-indigo-400/20 bg-indigo-500/10 px-4 py-1.5 text-xs font-semibold uppercase tracking-[0.2em] text-indigo-400">
114+
Organization Mega-City
115+
</div>
116+
<div className="flex gap-4">
117+
<Link
118+
href={`/dashboard/org/${orgname}?refresh=true`}
119+
className="flex items-center gap-2 rounded-xl border border-black/10 dark:border-[rgba(255,255,255,0.15)] bg-black dark:bg-black px-4 py-2 text-sm font-semibold text-white dark:text-white transition-all duration-200 hover:bg-gray-800 dark:hover:bg-white/10 active:scale-[0.98]"
120+
>
121+
Refresh Data
122+
</Link>
123+
<Link
124+
href="/"
125+
className="flex items-center gap-2 rounded-xl border border-black/10 dark:border-[rgba(255,255,255,0.15)] bg-gray-100 dark:bg-[#111] px-4 py-2 text-sm font-semibold text-black dark:text-white transition-all duration-200 hover:bg-gray-200 dark:hover:bg-white/10 active:scale-[0.98]"
126+
>
127+
Generate Yours
128+
</Link>
129+
</div>
130+
</div>
131+
132+
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr_320px] gap-6 lg:gap-8">
133+
{/* Left Sidebar */}
134+
<aside className="flex flex-col gap-6">
135+
<ProfileCard
136+
user={data.profile}
137+
exportData={{
138+
stats: data.stats,
139+
languages: [], // Orgs skip language donut chart for now
140+
}}
141+
/>
142+
<Achievements achievements={achievements} />
143+
</aside>
144+
145+
{/* Main Content */}
146+
<div className="flex flex-col gap-6 lg:gap-8 min-w-0">
147+
<section>
148+
<ActivityLandscape data={activity} />
149+
</section>
150+
151+
{/* Org specific layout: CommitClock takes full width of the main content column */}
152+
<section className="grid grid-cols-1 gap-6">
153+
<CommitClock data={commitClock} />
154+
</section>
155+
156+
<section>
157+
<Heatmap data={activity} />
158+
</section>
159+
</div>
160+
161+
{/* Right Sidebar */}
162+
<aside className="flex flex-col gap-6">
163+
<div className="flex flex-col gap-4">
164+
<StatsCard
165+
title="Team Current Streak"
166+
value={data.stats.currentStreak.toString()}
167+
description="Days"
168+
icon="Flame"
169+
/>
170+
171+
<StatsCard
172+
title="Team Peak Streak"
173+
value={data.stats.peakStreak.toString()}
174+
description="Days"
175+
icon="TrendingUp"
176+
/>
177+
178+
<StatsCard
179+
title="Total Team Commits"
180+
value={data.stats.totalContributions.toString()}
181+
description="Last 365 Days"
182+
icon="GitCommit"
183+
/>
184+
</div>
185+
186+
<AIInsights insights={insights} />
187+
</aside>
188+
</div>
189+
</div>
190+
);
191+
}

app/api/github/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
// app/api/github/route.ts
2+
13
import { NextResponse } from 'next/server';
24
import { getFullDashboardData } from '@/lib/github';
3-
import { githubParamsSchema } from '../../../lib/validations';
5+
import { githubParamsSchema } from '@/lib/validations';
46

57
/**
68
* Returns GitHub dashboard data as JSON.

app/api/og/route.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
// app/api/og/route.tsx
2+
13
import { ImageResponse } from 'next/og';
24
import { NextRequest } from 'next/server';
3-
import { ogParamsSchema } from '../../../lib/validations';
4-
import { fetchGitHubContributions } from '../../../lib/github';
5-
import { calculateStreak } from '../../../lib/calculate';
5+
import { ogParamsSchema } from '@/lib/validations';
6+
import { fetchGitHubContributions } from '@/lib/github';
7+
import { calculateStreak } from '@/lib/calculate';
68

79
export async function GET(req: NextRequest) {
810
const { searchParams } = new URL(req.url);

app/api/stats/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// app/api/stats/route.ts
22
import { NextResponse } from 'next/server';
3-
import { fetchGitHubContributions } from '../../../lib/github';
4-
import { calculateStreak } from '../../../lib/calculate';
5-
import { statsParamsSchema } from '../../../lib/validations';
3+
import { fetchGitHubContributions } from '@/lib/github';
4+
import { calculateStreak } from '@/lib/calculate';
5+
import { statsParamsSchema } from '@/lib/validations';
66

77
/**
88
* GET /api/stats?user=<username>[&refresh=true][&tz=<IANA timezone>]

0 commit comments

Comments
 (0)