Skip to content

Commit 24a2d61

Browse files
committed
feat: add dashboard JSON export
chore: format ShareSheet.tsx with Prettier to fix lint errors
1 parent ea9121e commit 24a2d61

5 files changed

Lines changed: 111 additions & 7 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@ export default async function DashboardPage({ params }: { params: Promise<{ user
8181
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr_320px] gap-6 lg:gap-8">
8282
{/* Left Sidebar */}
8383
<aside className="flex flex-col gap-6">
84-
<ProfileCard user={data.profile} />
84+
<ProfileCard
85+
user={data.profile}
86+
exportData={{ stats: data.stats, languages: data.languages }}
87+
/>
8588
{/* We omit real achievements data generation for now and just show a placeholder based on streaks */}
8689
<Achievements
8790
achievements={[

components/dashboard/ProfileCard.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@
33
import { useState } from 'react';
44
import { motion } from 'framer-motion';
55
import { MapPin, Calendar, GitBranch, Users, UserPlus, Star, Share2 } from 'lucide-react';
6-
import { UserProfile } from '@/types/dashboard';
6+
import type { DashboardExportData, UserProfile } from '@/types/dashboard';
77
import ShareSheet from './ShareSheet';
88

9-
export default function ProfileCard({ user }: { user: UserProfile }) {
9+
interface ProfileCardProps {
10+
user: UserProfile;
11+
exportData: DashboardExportData;
12+
}
13+
14+
export default function ProfileCard({ user, exportData }: ProfileCardProps) {
1015
const [shareOpen, setShareOpen] = useState(false);
1116

1217
return (
@@ -99,7 +104,12 @@ export default function ProfileCard({ user }: { user: UserProfile }) {
99104
</div>
100105
</motion.div>
101106

102-
<ShareSheet username={user.username} isOpen={shareOpen} onClose={() => setShareOpen(false)} />
107+
<ShareSheet
108+
username={user.username}
109+
isOpen={shareOpen}
110+
onClose={() => setShareOpen(false)}
111+
exportData={exportData}
112+
/>
103113
</>
104114
);
105115
}

components/dashboard/ShareSheet.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ describe('ShareSheet', () => {
3636
username: 'octocat',
3737
isOpen: true,
3838
onClose: vi.fn(),
39+
exportData: {
40+
stats: {
41+
currentStreak: 7,
42+
peakStreak: 14,
43+
totalContributions: 365,
44+
},
45+
languages: [
46+
{ name: 'TypeScript', percentage: 72, color: '#3178c6' },
47+
{ name: 'JavaScript', percentage: 28, color: '#f1e05a' },
48+
],
49+
},
3950
};
4051

4152
beforeEach(() => {
@@ -50,6 +61,15 @@ describe('ShareSheet', () => {
5061

5162
// Mock window.open
5263
vi.spyOn(window, 'open').mockImplementation(() => null);
64+
Object.defineProperty(URL, 'createObjectURL', {
65+
configurable: true,
66+
value: vi.fn().mockReturnValue('blob:mock-download'),
67+
});
68+
Object.defineProperty(URL, 'revokeObjectURL', {
69+
configurable: true,
70+
value: vi.fn(),
71+
});
72+
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
5373
});
5474

5575
afterEach(() => {
@@ -67,6 +87,7 @@ describe('ShareSheet', () => {
6787
expect(screen.getByText('@octocat')).toBeDefined();
6888
expect(screen.getByText('Copy Link')).toBeDefined();
6989
expect(screen.getByText('Share on X')).toBeDefined();
90+
expect(screen.getByText('Download JSON')).toBeDefined();
7091
});
7192

7293
it('calls onClose when close button is clicked', () => {
@@ -141,4 +162,29 @@ describe('ShareSheet', () => {
141162

142163
document.body.removeChild(mockRoot);
143164
});
165+
166+
it('downloads dashboard data as formatted JSON', async () => {
167+
render(<ShareSheet {...defaultProps} />);
168+
169+
const jsonButton = screen.getByText('Download JSON').closest('button');
170+
fireEvent.click(jsonButton!);
171+
172+
const blob = vi.mocked(URL.createObjectURL).mock.calls[0][0] as Blob;
173+
const json = JSON.parse(await blob.text());
174+
175+
expect(json).toMatchObject({
176+
username: 'octocat',
177+
currentStreak: 7,
178+
longestStreak: 14,
179+
totalContributions: 365,
180+
topLanguages: defaultProps.exportData.languages,
181+
});
182+
expect(json.profileUrl).toContain('/octocat');
183+
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled();
184+
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-download');
185+
186+
await waitFor(() => {
187+
expect(screen.getByText('JSON Downloaded!')).toBeDefined();
188+
});
189+
});
144190
});

components/dashboard/ShareSheet.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import { useState, useEffect, useRef } from 'react';
44
import { motion, AnimatePresence } from 'framer-motion';
5-
import { X, Link2, Download, Share2, Check, Loader2, Smartphone } from 'lucide-react';
5+
import { Check, Download, FileJson, Link2, Loader2, Share2, Smartphone, X } from 'lucide-react';
66
import { toPng } from 'html-to-image';
7+
import type { DashboardExportData } from '@/types/dashboard';
78

89
// Inline branded icons (Twitter/X brand, LinkedIn brand)
910
const XBrandIcon = ({ size = 18 }: { size?: number }) => (
@@ -22,6 +23,7 @@ interface ShareSheetProps {
2223
username: string;
2324
isOpen: boolean;
2425
onClose: () => void;
26+
exportData: DashboardExportData;
2527
}
2628

2729
type OptionState = 'idle' | 'loading' | 'success' | 'error';
@@ -31,7 +33,7 @@ const PROFILE_URL = (username: string) =>
3133
? `${window.location.origin}/${username}`
3234
: `https://commitpulse.vercel.app/${username}`;
3335

34-
export default function ShareSheet({ username, isOpen, onClose }: ShareSheetProps) {
36+
export default function ShareSheet({ username, isOpen, onClose, exportData }: ShareSheetProps) {
3537
const [states, setStates] = useState<Record<string, OptionState>>({});
3638
const overlayRef = useRef<HTMLDivElement>(null);
3739

@@ -116,6 +118,33 @@ export default function ShareSheet({ username, isOpen, onClose }: ShareSheetProp
116118
}
117119
};
118120

121+
const handleDownloadJSON = () => {
122+
setOptionState('json', 'loading');
123+
try {
124+
const payload = {
125+
username,
126+
profileUrl: PROFILE_URL(username),
127+
exportedAt: new Date().toISOString(),
128+
currentStreak: exportData.stats.currentStreak,
129+
longestStreak: exportData.stats.peakStreak,
130+
totalContributions: exportData.stats.totalContributions,
131+
topLanguages: exportData.languages,
132+
};
133+
const blob = new Blob([JSON.stringify(payload, null, 2)], {
134+
type: 'application/json',
135+
});
136+
const url = URL.createObjectURL(blob);
137+
const link = document.createElement('a');
138+
link.download = `commitpulse-${username}.json`;
139+
link.href = url;
140+
link.click();
141+
URL.revokeObjectURL(url);
142+
setOptionState('json', 'success');
143+
} catch {
144+
setOptionState('json', 'error');
145+
}
146+
};
147+
119148
const handleNativeShare = async () => {
120149
if (!('share' in navigator)) {
121150
// Graceful fallback: just copy the link
@@ -180,6 +209,15 @@ export default function ShareSheet({ username, isOpen, onClose }: ShareSheetProp
180209
glow: 'transparent',
181210
action: handleDownloadPNG,
182211
},
212+
{
213+
key: 'json',
214+
icon: FileJson,
215+
label: 'Download JSON',
216+
description: 'Export raw streak and language data',
217+
gradient: 'bg-zinc-800',
218+
glow: 'transparent',
219+
action: handleDownloadJSON,
220+
},
183221
{
184222
key: 'native',
185223
icon: typeof window !== 'undefined' && 'share' in navigator ? Smartphone : Share2,
@@ -275,7 +313,9 @@ export default function ShareSheet({ username, isOpen, onClose }: ShareSheetProp
275313
? 'Link Copied!'
276314
: opt.key === 'png'
277315
? 'Downloaded!'
278-
: opt.label
316+
: opt.key === 'json'
317+
? 'JSON Downloaded!'
318+
: opt.label
279319
: state === 'error'
280320
? 'Failed — try again'
281321
: opt.label}

types/dashboard.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,8 @@ export interface CommitClockData {
5151
hour: number; // 0 - 23
5252
commits: number;
5353
}
54+
55+
export interface DashboardExportData {
56+
stats: UserStats;
57+
languages: LanguageData[];
58+
}

0 commit comments

Comments
 (0)