Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions app/burnout-analyzer/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,24 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import BurnoutAnalyzerPage from './page';

let mockOwnerParam: string | null = null;
let mockRepoParam: string | null = null;

vi.mock('next/navigation', () => ({
useSearchParams: () => ({
get: (key: string) => {
if (key === 'owner') return mockOwnerParam;
if (key === 'repo') return mockRepoParam;
return null;
},
}),
}));

describe('BurnoutAnalyzerPage repository input handling', () => {
beforeEach(() => {
vi.clearAllMocks();
mockOwnerParam = null;
mockRepoParam = null;
});

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

Expand All @@ -60,4 +73,22 @@ describe('BurnoutAnalyzerPage repository input handling', () => {
'/api/repo-burnout?owner=facebook&repo=react&excludeBots=false'
);
});

it('automatically triggers search on load if owner and repo URL parameters are present', async () => {
mockOwnerParam = 'vercel';
mockRepoParam = 'next.js';

const fetchMock = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ error: 'stop here' }),
});
vi.stubGlobal('fetch', fetchMock);

render(<BurnoutAnalyzerPage />);

await waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(fetchMock.mock.calls[0][0]).toBe(
'/api/repo-burnout?owner=vercel&repo=next.js&excludeBots=false'
);
});
});
109 changes: 80 additions & 29 deletions app/burnout-analyzer/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useState } from 'react';
import { useState, useEffect, useRef, useCallback, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { Search, Flame } from 'lucide-react';
import dynamic from 'next/dynamic';
Expand Down Expand Up @@ -34,47 +35,82 @@ interface BurnoutReport {
recommendations: string[];
}

export default function BurnoutAnalyzerPage() {
function BurnoutAnalyzerContent() {
const searchParams = useSearchParams();
const [query, setQuery] = useState('');
const [report, setReport] = useState<BurnoutReport | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [excludeBots, setExcludeBots] = useState(false);
const initialFetchAttempted = useRef(false);

const executeSearch = useCallback(
async (rawPath: string) => {
const repoPath = rawPath.trim();
const segments = repoPath.split('/');
if (segments.length !== 2 || !segments[0].trim() || !segments[1].trim()) {
setError('Please enter a valid repository path in "owner/repo" format.');
return;
}
const owner = segments[0].trim();
const repo = segments[1].trim();

setIsLoading(true);
setError(null);
setReport(null);

try {
const res = await fetch(
`/api/repo-burnout?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}&excludeBots=${excludeBots}`
);
const data = await res.json();

if (!res.ok) {
throw new Error(data.error || 'Failed to analyze repository.');
}

setReport(data);
setQuery(repoPath);

if (typeof window !== 'undefined' && window.history) {
const newUrl = `${window.location.pathname}?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
window.history.pushState(null, '', newUrl);
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'An unexpected error occurred.');
} finally {
setIsLoading(false);
}
},
[excludeBots]
);

const handleSearch = async (e?: React.FormEvent, targetRepo?: string) => {
const handleSearch = (e?: React.FormEvent, targetRepo?: string) => {
if (e) e.preventDefault();
const repoPath = (targetRepo || query).trim();
const segments = repoPath.split('/');
if (segments.length !== 2 || !segments[0].trim() || !segments[1].trim()) {
setError('Please enter a valid repository path in "owner/repo" format.');
return;
}
const owner = segments[0].trim();
const repo = segments[1].trim();
executeSearch(targetRepo || query);
};

setIsLoading(true);
setError(null);
setReport(null);
useEffect(() => {
if (initialFetchAttempted.current) return;

try {
const res = await fetch(
`/api/repo-burnout?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}&excludeBots=${excludeBots}`
);
const data = await res.json();
const ownerParam = searchParams.get('owner');
const repoParam = searchParams.get('repo');

if (!res.ok) {
throw new Error(data.error || 'Failed to analyze repository.');
}
let initialRepo = '';
if (ownerParam && repoParam) {
initialRepo = `${ownerParam.trim()}/${repoParam.trim()}`;
} else if (repoParam && repoParam.includes('/')) {
initialRepo = repoParam.trim();
}

setReport(data);
setQuery(repoPath);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'An unexpected error occurred.');
} finally {
setIsLoading(false);
if (initialRepo) {
initialFetchAttempted.current = true;
setTimeout(() => {
executeSearch(initialRepo);
}, 0);
}
};
}, [searchParams, executeSearch]);

const handleRefresh = async () => {
if (!report) return;
Expand Down Expand Up @@ -330,3 +366,18 @@ export default function BurnoutAnalyzerPage() {
</div>
);
}

export default function BurnoutAnalyzerPage() {
return (
<Suspense
fallback={
<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">
<div className="h-32 w-full rounded-2xl shimmer" />
<div className="h-96 w-full rounded-2xl shimmer" />
</div>
}
>
<BurnoutAnalyzerContent />
</Suspense>
);
}
155 changes: 155 additions & 0 deletions components/burnout/DownloadReportMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import DownloadReportMenu from './DownloadReportMenu';
import type { BurnoutReport } from '@/services/github/burnout-analyzer';

vi.mock('@/utils/clipboard', () => ({
copyToClipboard: vi.fn().mockResolvedValue(undefined),
}));

const mockReport: BurnoutReport = {
repoName: 'facebook/react',
totalCommits: 1500,
totalContributors: 45,
busFactor: 2,
dependencyRisk: 'Medium',
sustainabilityScore: 78,
contributors: [
{
username: 'dan',
avatarUrl: 'https://github.com/dan.png',
totalCommits: 600,
commitShare: 40,
burnoutScore: 75,
riskLevel: 'High',
activeWeeks: 10,
highIntensityWeeks: 4,
consecutiveHighWeeks: 2,
restWeeks: 2,
recentTrend: [10, 15, 20],
recentAdditionsTrend: [100, 200],
},
{
username: 'sophie',
avatarUrl: 'https://github.com/sophie.png',
totalCommits: 400,
commitShare: 26.6,
burnoutScore: 50,
riskLevel: 'Medium',
activeWeeks: 8,
highIntensityWeeks: 2,
consecutiveHighWeeks: 1,
restWeeks: 4,
recentTrend: [5, 10, 8],
recentAdditionsTrend: [50, 100],
},
],
inactivityAlerts: [
{
username: 'gaearon',
avatarUrl: 'https://github.com/gaearon.png',
previousAvgWeeklyCommits: 8,
weeksSilent: 4,
severity: 'High',
},
],
recommendations: ['[AI Recommendation] Onboard more maintainers to lower bus factor risk.'],
};

describe('DownloadReportMenu Export & Download Functionality', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('renders Export Report button', () => {
render(<DownloadReportMenu report={mockReport} />);
expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument();
});

it('opens dropdown menu with all required export choices when clicked', () => {
render(<DownloadReportMenu report={mockReport} />);
fireEvent.click(screen.getByRole('button', { name: /export/i }));

expect(screen.getByText('Download as JSON')).toBeInTheDocument();
expect(screen.getByText('Export as Markdown')).toBeInTheDocument();
expect(screen.getByText('Copy Share Link')).toBeInTheDocument();
expect(screen.getByText('Copy Markdown Summary')).toBeInTheDocument();
expect(screen.getByText('Download as PDF')).toBeInTheDocument();
});

it('triggers JSON download when "Download as JSON" is clicked', () => {
render(<DownloadReportMenu report={mockReport} />);
fireEvent.click(screen.getByRole('button', { name: /export/i }));

const createObjectURLMock = vi.fn().mockReturnValue('blob:test');
const revokeObjectURLMock = vi.fn();
vi.stubGlobal('URL', {
createObjectURL: createObjectURLMock,
revokeObjectURL: revokeObjectURLMock,
});

const anchorClickMock = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});

fireEvent.click(screen.getByText('Download as JSON'));

expect(createObjectURLMock).toHaveBeenCalled();
expect(anchorClickMock).toHaveBeenCalled();
expect(screen.getByText(/JSON report downloaded!/i)).toBeInTheDocument();
});

it('triggers Markdown download when "Export as Markdown" is clicked', () => {
render(<DownloadReportMenu report={mockReport} />);
fireEvent.click(screen.getByRole('button', { name: /export/i }));

const createObjectURLMock = vi.fn().mockReturnValue('blob:test');
const revokeObjectURLMock = vi.fn();
vi.stubGlobal('URL', {
createObjectURL: createObjectURLMock,
revokeObjectURL: revokeObjectURLMock,
});

const anchorClickMock = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});

fireEvent.click(screen.getByText('Export as Markdown'));

expect(createObjectURLMock).toHaveBeenCalled();
expect(anchorClickMock).toHaveBeenCalled();
expect(screen.getByText(/Markdown report downloaded!/i)).toBeInTheDocument();
});

it('copies share link and displays toast notification when "Copy Share Link" is clicked', async () => {
const { copyToClipboard } = await import('@/utils/clipboard');
render(<DownloadReportMenu report={mockReport} />);
fireEvent.click(screen.getByRole('button', { name: /export/i }));

fireEvent.click(screen.getByText('Copy Share Link'));

await waitFor(() => {
expect(copyToClipboard).toHaveBeenCalledWith(
expect.stringContaining('/burnout-analyzer?owner=facebook&repo=react')
);
});

expect(screen.getByText(/Share link copied to clipboard!/i)).toBeInTheDocument();
});

it('copies summary and displays toast when "Copy Markdown Summary" is clicked', async () => {
const { copyToClipboard } = await import('@/utils/clipboard');
render(<DownloadReportMenu report={mockReport} />);
fireEvent.click(screen.getByRole('button', { name: /export/i }));

fireEvent.click(screen.getByText('Copy Markdown Summary'));

await waitFor(() => {
expect(copyToClipboard).toHaveBeenCalledWith(
expect.stringContaining('Team Health Report: facebook/react')
);
});

expect(screen.getByText(/Markdown report copied to clipboard!/i)).toBeInTheDocument();
});
});
Loading
Loading