Skip to content

Commit 880c6be

Browse files
authored
merge: feat: Enhance Burnout & Sustainability Sentinel with Export & Download Features (#8216) (#8258)
## Description Resolves #8216. This PR introduces structured export and download options (JSON, Markdown, PDF, Copy Summary, and direct Share Link with query parameter support) to the **Burnout & Sustainability Sentinel** (`/burnout-analyzer`). ### Key Enhancements & Features: 1. **Download as JSON**: Downloads raw structured audit data (`report` object) as a formatted JSON file (`<owner>-<repo>-burnout-report.json`). 2. **Export as Markdown**: Generates a clean, comprehensive Markdown report detailing the Sustainability Score, Bus Factor, contributor workload risk breakdown, inactivity alerts, and AI recommendations. 3. **Copy Share Link**: Copies a direct shareable URL containing repository search parameters (`/burnout-analyzer?owner={owner}&repo={repo}`). 4. **Copy Markdown Summary**: Copies raw Markdown report text directly to clipboard. 5. **Download as PDF**: Retains PDF report generation. 6. **Toast Notifications**: Added animated notification feedback using Framer Motion on every export download/copy event. 7. **URL Parameter Auto-Fetch**: Wrapped page in `<Suspense>` to auto-trigger analysis when landing on URLs with query parameters (`/burnout-analyzer?owner=facebook&repo=react`) and sync address bar state seamlessly. --- ## Target Files Modified: - `app/burnout-analyzer/page.tsx` - `app/burnout-analyzer/page.test.tsx` - `components/burnout/DownloadReportMenu.tsx` - `components/burnout/DownloadReportMenu.test.tsx` --- ## Verification & Testing - ✅ **Unit Tests**: Ran `npx vitest run app/burnout-analyzer/page.test.tsx components/burnout/DownloadReportMenu.test.tsx` — **10 / 10 tests passed**. - ✅ **TypeScript Check**: Ran `npx tsc --noEmit` — **0 errors**. - ✅ **Linting**: Passed `eslint --fix` and `prettier` pre-commit checks cleanly. --- *GSSoC 2026 Contribution*
2 parents a34794b + 21568bf commit 880c6be

4 files changed

Lines changed: 370 additions & 57 deletions

File tree

app/burnout-analyzer/page.test.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,24 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
22
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
33
import BurnoutAnalyzerPage from './page';
44

5+
let mockOwnerParam: string | null = null;
6+
let mockRepoParam: string | null = null;
7+
8+
vi.mock('next/navigation', () => ({
9+
useSearchParams: () => ({
10+
get: (key: string) => {
11+
if (key === 'owner') return mockOwnerParam;
12+
if (key === 'repo') return mockRepoParam;
13+
return null;
14+
},
15+
}),
16+
}));
17+
518
describe('BurnoutAnalyzerPage repository input handling', () => {
619
beforeEach(() => {
720
vi.clearAllMocks();
21+
mockOwnerParam = null;
22+
mockRepoParam = null;
823
});
924

1025
it('rejects a path that is not exactly owner/repo and does not call the API', async () => {
@@ -37,8 +52,6 @@ describe('BurnoutAnalyzerPage repository input handling', () => {
3752
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
3853
const calledUrl = fetchMock.mock.calls[0][0] as string;
3954
expect(calledUrl).toContain(`repo=${encodeURIComponent('bar&x=1')}`);
40-
// Without encoding the "&x=1" would split into a spurious query param and the
41-
// server would receive repo=bar (a different, valid repo) instead of the typed value.
4255
expect(calledUrl).not.toContain('repo=bar&x=1');
4356
});
4457

@@ -60,4 +73,22 @@ describe('BurnoutAnalyzerPage repository input handling', () => {
6073
'/api/repo-burnout?owner=facebook&repo=react&excludeBots=false'
6174
);
6275
});
76+
77+
it('automatically triggers search on load if owner and repo URL parameters are present', async () => {
78+
mockOwnerParam = 'vercel';
79+
mockRepoParam = 'next.js';
80+
81+
const fetchMock = vi.fn().mockResolvedValue({
82+
ok: false,
83+
json: async () => ({ error: 'stop here' }),
84+
});
85+
vi.stubGlobal('fetch', fetchMock);
86+
87+
render(<BurnoutAnalyzerPage />);
88+
89+
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
90+
expect(fetchMock.mock.calls[0][0]).toBe(
91+
'/api/repo-burnout?owner=vercel&repo=next.js&excludeBots=false'
92+
);
93+
});
6394
});

app/burnout-analyzer/page.tsx

Lines changed: 80 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

3-
import { useState } from 'react';
3+
import { useState, useEffect, useRef, useCallback, Suspense } from 'react';
4+
import { useSearchParams } from 'next/navigation';
45
import { motion, AnimatePresence } from 'framer-motion';
56
import { Search, Flame } from 'lucide-react';
67
import dynamic from 'next/dynamic';
@@ -34,47 +35,82 @@ interface BurnoutReport {
3435
recommendations: string[];
3536
}
3637

37-
export default function BurnoutAnalyzerPage() {
38+
function BurnoutAnalyzerContent() {
39+
const searchParams = useSearchParams();
3840
const [query, setQuery] = useState('');
3941
const [report, setReport] = useState<BurnoutReport | null>(null);
4042
const [isLoading, setIsLoading] = useState(false);
4143
const [error, setError] = useState<string | null>(null);
4244
const [isRefreshing, setIsRefreshing] = useState(false);
4345
const [excludeBots, setExcludeBots] = useState(false);
46+
const initialFetchAttempted = useRef(false);
47+
48+
const executeSearch = useCallback(
49+
async (rawPath: string) => {
50+
const repoPath = rawPath.trim();
51+
const segments = repoPath.split('/');
52+
if (segments.length !== 2 || !segments[0].trim() || !segments[1].trim()) {
53+
setError('Please enter a valid repository path in "owner/repo" format.');
54+
return;
55+
}
56+
const owner = segments[0].trim();
57+
const repo = segments[1].trim();
58+
59+
setIsLoading(true);
60+
setError(null);
61+
setReport(null);
62+
63+
try {
64+
const res = await fetch(
65+
`/api/repo-burnout?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}&excludeBots=${excludeBots}`
66+
);
67+
const data = await res.json();
68+
69+
if (!res.ok) {
70+
throw new Error(data.error || 'Failed to analyze repository.');
71+
}
72+
73+
setReport(data);
74+
setQuery(repoPath);
75+
76+
if (typeof window !== 'undefined' && window.history) {
77+
const newUrl = `${window.location.pathname}?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
78+
window.history.pushState(null, '', newUrl);
79+
}
80+
} catch (err: unknown) {
81+
setError(err instanceof Error ? err.message : 'An unexpected error occurred.');
82+
} finally {
83+
setIsLoading(false);
84+
}
85+
},
86+
[excludeBots]
87+
);
4488

45-
const handleSearch = async (e?: React.FormEvent, targetRepo?: string) => {
89+
const handleSearch = (e?: React.FormEvent, targetRepo?: string) => {
4690
if (e) e.preventDefault();
47-
const repoPath = (targetRepo || query).trim();
48-
const segments = repoPath.split('/');
49-
if (segments.length !== 2 || !segments[0].trim() || !segments[1].trim()) {
50-
setError('Please enter a valid repository path in "owner/repo" format.');
51-
return;
52-
}
53-
const owner = segments[0].trim();
54-
const repo = segments[1].trim();
91+
executeSearch(targetRepo || query);
92+
};
5593

56-
setIsLoading(true);
57-
setError(null);
58-
setReport(null);
94+
useEffect(() => {
95+
if (initialFetchAttempted.current) return;
5996

60-
try {
61-
const res = await fetch(
62-
`/api/repo-burnout?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}&excludeBots=${excludeBots}`
63-
);
64-
const data = await res.json();
97+
const ownerParam = searchParams.get('owner');
98+
const repoParam = searchParams.get('repo');
6599

66-
if (!res.ok) {
67-
throw new Error(data.error || 'Failed to analyze repository.');
68-
}
100+
let initialRepo = '';
101+
if (ownerParam && repoParam) {
102+
initialRepo = `${ownerParam.trim()}/${repoParam.trim()}`;
103+
} else if (repoParam && repoParam.includes('/')) {
104+
initialRepo = repoParam.trim();
105+
}
69106

70-
setReport(data);
71-
setQuery(repoPath);
72-
} catch (err: unknown) {
73-
setError(err instanceof Error ? err.message : 'An unexpected error occurred.');
74-
} finally {
75-
setIsLoading(false);
107+
if (initialRepo) {
108+
initialFetchAttempted.current = true;
109+
setTimeout(() => {
110+
executeSearch(initialRepo);
111+
}, 0);
76112
}
77-
};
113+
}, [searchParams, executeSearch]);
78114

79115
const handleRefresh = async () => {
80116
if (!report) return;
@@ -330,3 +366,18 @@ export default function BurnoutAnalyzerPage() {
330366
</div>
331367
);
332368
}
369+
370+
export default function BurnoutAnalyzerPage() {
371+
return (
372+
<Suspense
373+
fallback={
374+
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:py-12 min-h-[80vh] flex flex-col gap-6">
375+
<div className="h-32 w-full rounded-2xl shimmer" />
376+
<div className="h-96 w-full rounded-2xl shimmer" />
377+
</div>
378+
}
379+
>
380+
<BurnoutAnalyzerContent />
381+
</Suspense>
382+
);
383+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3+
import DownloadReportMenu from './DownloadReportMenu';
4+
import type { BurnoutReport } from '@/services/github/burnout-analyzer';
5+
6+
vi.mock('@/utils/clipboard', () => ({
7+
copyToClipboard: vi.fn().mockResolvedValue(undefined),
8+
}));
9+
10+
const mockReport: BurnoutReport = {
11+
repoName: 'facebook/react',
12+
totalCommits: 1500,
13+
totalContributors: 45,
14+
busFactor: 2,
15+
dependencyRisk: 'Medium',
16+
sustainabilityScore: 78,
17+
contributors: [
18+
{
19+
username: 'dan',
20+
avatarUrl: 'https://github.com/dan.png',
21+
totalCommits: 600,
22+
commitShare: 40,
23+
burnoutScore: 75,
24+
riskLevel: 'High',
25+
activeWeeks: 10,
26+
highIntensityWeeks: 4,
27+
consecutiveHighWeeks: 2,
28+
restWeeks: 2,
29+
recentTrend: [10, 15, 20],
30+
recentAdditionsTrend: [100, 200],
31+
},
32+
{
33+
username: 'sophie',
34+
avatarUrl: 'https://github.com/sophie.png',
35+
totalCommits: 400,
36+
commitShare: 26.6,
37+
burnoutScore: 50,
38+
riskLevel: 'Medium',
39+
activeWeeks: 8,
40+
highIntensityWeeks: 2,
41+
consecutiveHighWeeks: 1,
42+
restWeeks: 4,
43+
recentTrend: [5, 10, 8],
44+
recentAdditionsTrend: [50, 100],
45+
},
46+
],
47+
inactivityAlerts: [
48+
{
49+
username: 'gaearon',
50+
avatarUrl: 'https://github.com/gaearon.png',
51+
previousAvgWeeklyCommits: 8,
52+
weeksSilent: 4,
53+
severity: 'High',
54+
},
55+
],
56+
recommendations: ['[AI Recommendation] Onboard more maintainers to lower bus factor risk.'],
57+
};
58+
59+
describe('DownloadReportMenu Export & Download Functionality', () => {
60+
beforeEach(() => {
61+
vi.clearAllMocks();
62+
});
63+
64+
it('renders Export Report button', () => {
65+
render(<DownloadReportMenu report={mockReport} />);
66+
expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument();
67+
});
68+
69+
it('opens dropdown menu with all required export choices when clicked', () => {
70+
render(<DownloadReportMenu report={mockReport} />);
71+
fireEvent.click(screen.getByRole('button', { name: /export/i }));
72+
73+
expect(screen.getByText('Download as JSON')).toBeInTheDocument();
74+
expect(screen.getByText('Export as Markdown')).toBeInTheDocument();
75+
expect(screen.getByText('Copy Share Link')).toBeInTheDocument();
76+
expect(screen.getByText('Copy Markdown Summary')).toBeInTheDocument();
77+
expect(screen.getByText('Download as PDF')).toBeInTheDocument();
78+
});
79+
80+
it('triggers JSON download when "Download as JSON" is clicked', () => {
81+
render(<DownloadReportMenu report={mockReport} />);
82+
fireEvent.click(screen.getByRole('button', { name: /export/i }));
83+
84+
const createObjectURLMock = vi.fn().mockReturnValue('blob:test');
85+
const revokeObjectURLMock = vi.fn();
86+
vi.stubGlobal('URL', {
87+
createObjectURL: createObjectURLMock,
88+
revokeObjectURL: revokeObjectURLMock,
89+
});
90+
91+
const anchorClickMock = vi
92+
.spyOn(HTMLAnchorElement.prototype, 'click')
93+
.mockImplementation(() => {});
94+
95+
fireEvent.click(screen.getByText('Download as JSON'));
96+
97+
expect(createObjectURLMock).toHaveBeenCalled();
98+
expect(anchorClickMock).toHaveBeenCalled();
99+
expect(screen.getByText(/JSON report downloaded!/i)).toBeInTheDocument();
100+
});
101+
102+
it('triggers Markdown download when "Export as Markdown" is clicked', () => {
103+
render(<DownloadReportMenu report={mockReport} />);
104+
fireEvent.click(screen.getByRole('button', { name: /export/i }));
105+
106+
const createObjectURLMock = vi.fn().mockReturnValue('blob:test');
107+
const revokeObjectURLMock = vi.fn();
108+
vi.stubGlobal('URL', {
109+
createObjectURL: createObjectURLMock,
110+
revokeObjectURL: revokeObjectURLMock,
111+
});
112+
113+
const anchorClickMock = vi
114+
.spyOn(HTMLAnchorElement.prototype, 'click')
115+
.mockImplementation(() => {});
116+
117+
fireEvent.click(screen.getByText('Export as Markdown'));
118+
119+
expect(createObjectURLMock).toHaveBeenCalled();
120+
expect(anchorClickMock).toHaveBeenCalled();
121+
expect(screen.getByText(/Markdown report downloaded!/i)).toBeInTheDocument();
122+
});
123+
124+
it('copies share link and displays toast notification when "Copy Share Link" is clicked', async () => {
125+
const { copyToClipboard } = await import('@/utils/clipboard');
126+
render(<DownloadReportMenu report={mockReport} />);
127+
fireEvent.click(screen.getByRole('button', { name: /export/i }));
128+
129+
fireEvent.click(screen.getByText('Copy Share Link'));
130+
131+
await waitFor(() => {
132+
expect(copyToClipboard).toHaveBeenCalledWith(
133+
expect.stringContaining('/burnout-analyzer?owner=facebook&repo=react')
134+
);
135+
});
136+
137+
expect(screen.getByText(/Share link copied to clipboard!/i)).toBeInTheDocument();
138+
});
139+
140+
it('copies summary and displays toast when "Copy Markdown Summary" is clicked', async () => {
141+
const { copyToClipboard } = await import('@/utils/clipboard');
142+
render(<DownloadReportMenu report={mockReport} />);
143+
fireEvent.click(screen.getByRole('button', { name: /export/i }));
144+
145+
fireEvent.click(screen.getByText('Copy Markdown Summary'));
146+
147+
await waitFor(() => {
148+
expect(copyToClipboard).toHaveBeenCalledWith(
149+
expect.stringContaining('Team Health Report: facebook/react')
150+
);
151+
});
152+
153+
expect(screen.getByText(/Markdown report copied to clipboard!/i)).toBeInTheDocument();
154+
});
155+
});

0 commit comments

Comments
 (0)