diff --git a/app/burnout-analyzer/page.test.tsx b/app/burnout-analyzer/page.test.tsx index e33b4d47a..9b26b852b 100644 --- a/app/burnout-analyzer/page.test.tsx +++ b/app/burnout-analyzer/page.test.tsx @@ -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 () => { @@ -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'); }); @@ -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(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(fetchMock.mock.calls[0][0]).toBe( + '/api/repo-burnout?owner=vercel&repo=next.js&excludeBots=false' + ); + }); }); diff --git a/app/burnout-analyzer/page.tsx b/app/burnout-analyzer/page.tsx index 0623b3334..638dfa2b3 100644 --- a/app/burnout-analyzer/page.tsx +++ b/app/burnout-analyzer/page.tsx @@ -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'; @@ -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(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(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; @@ -330,3 +366,18 @@ export default function BurnoutAnalyzerPage() { ); } + +export default function BurnoutAnalyzerPage() { + return ( + +
+
+
+ } + > + + + ); +} diff --git a/components/burnout/DownloadReportMenu.test.tsx b/components/burnout/DownloadReportMenu.test.tsx new file mode 100644 index 000000000..3b9761318 --- /dev/null +++ b/components/burnout/DownloadReportMenu.test.tsx @@ -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(); + expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument(); + }); + + it('opens dropdown menu with all required export choices when clicked', () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/components/burnout/DownloadReportMenu.tsx b/components/burnout/DownloadReportMenu.tsx index d08124f53..2bcfb4d4a 100644 --- a/components/burnout/DownloadReportMenu.tsx +++ b/components/burnout/DownloadReportMenu.tsx @@ -2,20 +2,30 @@ import { copyToClipboard } from '@/utils/clipboard'; import { useState } from 'react'; -import { Download, FileText, Copy, FileDown, Check } from 'lucide-react'; +import { Download, FileText, Copy, FileDown, Check, FileCode, Link } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import type { BurnoutReport } from '@/services/github/burnout-analyzer'; import jsPDF from 'jspdf'; export default function DownloadReportMenu({ report }: { report: BurnoutReport }) { const [isOpen, setIsOpen] = useState(false); - const [copied, setCopied] = useState(false); + const [copiedLink, setCopiedLink] = useState(false); + const [copiedSummary, setCopiedSummary] = useState(false); + const [toastMessage, setToastMessage] = useState(null); + + const triggerToast = (msg: string) => { + setToastMessage(msg); + setTimeout(() => { + setToastMessage(null); + }, 2800); + }; const generateMarkdown = () => { const date = new Date().toLocaleDateString(); let md = `# Team Health Report: ${report.repoName}\n\n`; md += `**Analysis Date:** ${date}\n\n`; - md += `## Overall Risk: ${report.dependencyRisk}\n\n`; + md += `## Sustainability Score: ${report.sustainabilityScore}/100\n`; + md += `## Dependency Risk (Bus Factor ${report.busFactor}): ${report.dependencyRisk}\n\n`; md += `### Key Findings:\n`; if (report.contributors.length > 0) { @@ -23,7 +33,7 @@ export default function DownloadReportMenu({ report }: { report: BurnoutReport } const topShare = report.contributors .slice(0, topCount) .reduce((acc, c) => acc + c.commitShare, 0); - md += `- ${topCount} contributors handled ${Math.round(topShare)}% of recent commits.\n`; + md += `- ${topCount} contributor(s) handled ${Math.round(topShare)}% of recent commits.\n`; } const inactiveCount = report.inactivityAlerts.length; @@ -38,7 +48,7 @@ export default function DownloadReportMenu({ report }: { report: BurnoutReport } md += `- Maintenance load is fairly distributed among active contributors.\n`; } - md += `\n### Recommendations:\n`; + md += `\n### AI Recommendations:\n`; if (report.recommendations.length > 0) { report.recommendations.forEach((rec) => { const cleanRec = rec.replace('[AI Recommendation] ', ''); @@ -52,28 +62,58 @@ export default function DownloadReportMenu({ report }: { report: BurnoutReport } return md; }; + const handleDownloadJson = () => { + const jsonStr = JSON.stringify(report, null, 2); + const blob = new Blob([jsonStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${report.repoName.replace('/', '-')}-burnout-report.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + setIsOpen(false); + triggerToast('✓ JSON report downloaded!'); + }; + const handleDownloadMd = () => { const md = generateMarkdown(); const blob = new Blob([md], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${report.repoName.replace('/', '-')}-health-report.md`; + a.download = `${report.repoName.replace('/', '-')}-burnout-report.md`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); setIsOpen(false); + triggerToast('✓ Markdown report downloaded!'); + }; + + const handleCopyShareLink = async () => { + const [owner, repo] = report.repoName.split('/'); + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + const shareUrl = `${origin}/burnout-analyzer?owner=${encodeURIComponent(owner || '')}&repo=${encodeURIComponent(repo || '')}`; + await copyToClipboard(shareUrl); + setCopiedLink(true); + triggerToast('✓ Share link copied to clipboard!'); + setTimeout(() => { + setCopiedLink(false); + setIsOpen(false); + }, 1800); }; const handleCopySummary = async () => { const md = generateMarkdown(); await copyToClipboard(md); - setCopied(true); + setCopiedSummary(true); + triggerToast('✓ Markdown report copied to clipboard!'); setTimeout(() => { - setCopied(false); + setCopiedSummary(false); setIsOpen(false); - }, 2000); + }, 1800); }; const handleDownloadPdf = () => { @@ -84,8 +124,9 @@ export default function DownloadReportMenu({ report }: { report: BurnoutReport } doc.setFontSize(12); const lines = doc.splitTextToSize(md, 180); doc.text(lines, 10, 30); - doc.save(`${report.repoName.replace('/', '-')}-health-report.pdf`); + doc.save(`${report.repoName.replace('/', '-')}-burnout-report.pdf`); setIsOpen(false); + triggerToast('✓ PDF report downloaded!'); }; return ( @@ -95,8 +136,8 @@ export default function DownloadReportMenu({ report }: { report: BurnoutReport } className="flex items-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white rounded-xl text-sm font-medium transition-colors shadow-sm active:scale-95" > - Download Report - Report + Export Report + Export @@ -106,39 +147,74 @@ export default function DownloadReportMenu({ report }: { report: BurnoutReport } animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 10, scale: 0.95 }} transition={{ duration: 0.15 }} - className="absolute right-0 mt-2 w-56 bg-white dark:bg-[#111] border border-black/10 dark:border-white/10 rounded-xl shadow-lg overflow-hidden z-50" + className="absolute right-0 mt-2 w-60 bg-white dark:bg-[#111] border border-black/10 dark:border-white/10 rounded-xl shadow-lg overflow-hidden z-50" > -
+
+ + + + + +
)} + + {toastMessage && ( + + {toastMessage} + + )} + + {isOpen &&
setIsOpen(false)} />}
);