Skip to content
Open
668 changes: 668 additions & 0 deletions src/components/RepoHealthDrawer.jsx

Large diffs are not rendered by default.

171 changes: 171 additions & 0 deletions src/components/RepoHealthDrawer.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React from 'react'
import RepoHealthDrawer from './RepoHealthDrawer'

const sampleRepo = {
name: 'OrgExplorer',
orgLogin: 'AOSSIE-Org',
healthScore: 61,
pushed_at: new Date(Date.now() - 5 * 86_400_000).toISOString(),
created_at: '2024-01-01T00:00:00Z',
stargazers_count: 150,
forks_count: 45,
open_issues_count: 12,
language: 'JavaScript',
default_branch: 'main',
html_url: 'https://github.com/AOSSIE-Org/OrgExplorer',
license: { name: 'MIT License', spdx_id: 'MIT' },
activityClassification: 'Thriving',
contributors: [
{ login: 'contributor1', contributions: 20 },
{ login: 'contributor2', contributions: 15 },
{ login: 'contributor3', contributions: 10 },
],
busFactor: { factor: 2, risk: 'high' },
}

describe('RepoHealthDrawer', () => {
it('does not render when isOpen is false', () => {
const { container } = render(
<RepoHealthDrawer repo={sampleRepo} isOpen={false} onClose={() => {}} />
)
expect(container.firstChild).toBeNull()
})

it('renders modal dialog with accessible name, receives initial focus, and displays computed score', () => {
render(<RepoHealthDrawer repo={sampleRepo} isOpen={true} onClose={() => {}} />)

const dialog = screen.getByRole('dialog', { name: /OrgExplorer/i })
expect(dialog).toBeInTheDocument()
expect(dialog).toHaveFocus()

expect(screen.getByText('OrgExplorer')).toBeInTheDocument()
expect(screen.getByText('AOSSIE-Org')).toBeInTheDocument()
expect(screen.getByText('61')).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /Score Breakdown/i })).toHaveAttribute('aria-selected', 'true')
})

it('keeps focus inside the dialog at both tab boundaries', () => {
render(<RepoHealthDrawer repo={sampleRepo} isOpen={true} onClose={() => {}} />)

const firstFocusable = screen.getByRole('button', { name: /Close health details/i })
const lastFocusable = screen.getByRole('tabpanel', { name: /Score Breakdown/i })

lastFocusable.focus()
fireEvent.keyDown(window, { key: 'Tab' })
expect(firstFocusable).toHaveFocus()

firstFocusable.focus()
fireEvent.keyDown(window, { key: 'Tab', shiftKey: true })
expect(lastFocusable).toHaveFocus()
})

it('renders category breakdown cards on default tab', () => {
render(<RepoHealthDrawer repo={sampleRepo} isOpen={true} onClose={() => {}} />)

expect(screen.getByText('Activity Health')).toBeInTheDocument()
expect(screen.getByText('Issue Health')).toBeInTheDocument()
expect(screen.getByText('Contributor Diversity')).toBeInTheDocument()
expect(screen.getByText('Scoring Formula')).toBeInTheDocument()
expect(screen.getByRole('tabpanel', { name: /Score Breakdown/i })).toBeInTheDocument()
})

it('switches to Recommendations tab on click and updates selected tab state', () => {
render(<RepoHealthDrawer repo={sampleRepo} isOpen={true} onClose={() => {}} />)

const recTab = screen.getByRole('tab', { name: /Recommendations/i })
fireEvent.click(recTab)

expect(recTab).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tabpanel', { name: /Recommendations/i })).toBeInTheDocument()
expect(screen.getByText(/Strong Development Momentum/i)).toBeInTheDocument()
})

it('supports activating tabs with user interaction', async () => {
const user = userEvent.setup()
render(<RepoHealthDrawer repo={sampleRepo} isOpen={true} onClose={() => {}} />)

const recTab = screen.getByRole('tab', { name: /Recommendations/i })
await user.click(recTab)

expect(recTab).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText(/Strong Development Momentum/i)).toBeInTheDocument()
})

it('switches to Raw Metrics tab and displays repository attributes', () => {
render(<RepoHealthDrawer repo={sampleRepo} isOpen={true} onClose={() => {}} />)

const rawTab = screen.getByRole('tab', { name: /Raw Metrics/i })
fireEvent.click(rawTab)

expect(rawTab).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tabpanel', { name: /Raw Metrics/i })).toBeInTheDocument()
expect(screen.getByText('Primary Language')).toBeInTheDocument()
expect(screen.getByText('JavaScript')).toBeInTheDocument()
expect(screen.getByText('150')).toBeInTheDocument()
})

it('triggers onClose when close button or backdrop is clicked', () => {
const onClose = vi.fn()
render(<RepoHealthDrawer repo={sampleRepo} isOpen={true} onClose={onClose} />)

const closeBtn = screen.getByRole('button', { name: /Close health details/i })
fireEvent.click(closeBtn)
expect(onClose).toHaveBeenCalledTimes(1)

const backdrop = screen.getByTestId('drawer-backdrop')
fireEvent.click(backdrop)
expect(onClose).toHaveBeenCalledTimes(2)
})

it('triggers onClose when Escape key is pressed', () => {
const onClose = vi.fn()
render(<RepoHealthDrawer repo={sampleRepo} isOpen={true} onClose={onClose} />)

fireEvent.keyDown(window, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})

it('displays Unknown for Activity Classification in Raw Metrics when pushed_at is missing or invalid', () => {
const repoWithInvalidDate = {
...sampleRepo,
pushed_at: null,
activityClassification: 'Hibernating',
}
render(<RepoHealthDrawer repo={repoWithInvalidDate} isOpen={true} onClose={() => {}} />)

const rawTab = screen.getByRole('tab', { name: /Raw Metrics/i })
fireEvent.click(rawTab)

expect(screen.getByRole('tabpanel', { name: /Raw Metrics/i })).toBeInTheDocument()
expect(screen.getByText('Activity Classification')).toBeInTheDocument()
expect(screen.getByText('Unknown')).toBeInTheDocument()
expect(screen.queryByText('Hibernating')).not.toBeInTheDocument()
})

it('handles unfetched contributor data by displaying Unavailable and avoiding zero-based single maintainer warnings', () => {
const repoWithUnfetchedContribs = {
...sampleRepo,
contributors: [],
contributorsFetched: false,
}
render(<RepoHealthDrawer repo={repoWithUnfetchedContribs} isOpen={true} onClose={() => {}} />)

// Breakdown tab should show UNAVAILABLE for diversity
expect(screen.getByText('Contributor Diversity')).toBeInTheDocument()
expect(screen.getByText('UNAVAILABLE')).toBeInTheDocument()

// Raw metrics tab should show Unavailable for Recorded Contributors
const rawTab = screen.getByRole('tab', { name: /Raw Metrics/i })
fireEvent.click(rawTab)
expect(screen.getByText('Recorded Contributors')).toBeInTheDocument()
expect(screen.getByText('Unavailable')).toBeInTheDocument()

// Recommendations tab should not flag single maintainer risk
const recTab = screen.getByRole('tab', { name: /Recommendations/i })
fireEvent.click(recTab)
expect(screen.queryByText(/Mitigate Single Maintainer Risk/i)).not.toBeInTheDocument()
})
})
36 changes: 31 additions & 5 deletions src/components/UI.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,40 @@ export function Badge({ text, variant }) {
return <span style={C.pill(color, bg)}>{String(text).toUpperCase()}</span>
}

export function HealthBar({ score }) {
export function HealthBar({ score, onClick, isInteractive = false, title, ariaLabel }) {
const color = score >= 70 ? 'var(--green)' : score >= 40 ? 'var(--amber)' : 'var(--red)'
const interactive = typeof onClick === 'function' && isInteractive !== false

const handleKeyDown = e => {
if (interactive && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
onClick?.()
}
}

return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ flex: 1, height: 4, background: 'var(--border)', borderRadius: 2 }}>
<div style={{ width: `${score}%`, height: '100%', background: color, borderRadius: 2 }} />
<div
role={interactive ? 'button' : undefined}
tabIndex={interactive ? 0 : undefined}
onClick={interactive ? onClick : undefined}
onKeyDown={interactive ? handleKeyDown : undefined}
title={title || (interactive ? 'Click to view health score breakdown & recommendations' : undefined)}
aria-label={interactive ? (ariaLabel || `Health score ${score} out of 100. Open breakdown.`) : undefined}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
cursor: interactive ? 'pointer' : 'default',
padding: interactive ? '2px 4px' : undefined,
borderRadius: interactive ? 4 : undefined,
transition: 'background 0.15s ease',
}}
className={interactive ? 'hover:bg-(--surface2)' : undefined}
>
<div style={{ flex: 1, height: 5, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: `${Math.min(100, Math.max(0, score))}%`, height: '100%', background: color, borderRadius: 3, transition: 'width 0.3s ease' }} />
</div>
<span style={{ fontSize: 12, fontWeight: 600, color, minWidth: 26 }}>{score}</span>
<span style={{ fontSize: 12, fontWeight: 600, color, minWidth: 26, textAlign: 'right' }}>{score}</span>
</div>
)
}
Expand Down
35 changes: 33 additions & 2 deletions src/pages/OverviewPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import AnalysisBanner from '../components/AnalysisBanner'
import { OverviewSkeleton } from '../components/Orgexplorerskeletons'
import {formatNumber} from '../utils/formatNumber'
import { useTheme } from '../context/ThemeContext'
import RepoHealthDrawer from '../components/RepoHealthDrawer'

const LANG_COLORS = ['#22c55e', '#f5c518', '#3b82f6', '#ef4444', '#a855f7', '#f97316', '#06b6d4']
const fmt = n => n > 999 ? (n / 1000).toFixed(1) + 'k' : String(n)
Expand All @@ -20,6 +21,7 @@ export default function OverviewPage() {
const [open, setOpen] = useState(false)
const [orgFilter, setOrgFilter] = useState('All Organizations')
const [showAllOrgs, setShowAllOrgs] = useState(false)
const [selectedRepoForHealth, setSelectedRepoForHealth] = useState(null)
const infoRef = useRef(null)

useEffect(() => {
Expand Down Expand Up @@ -279,9 +281,32 @@ export default function OverviewPage() {
{topRepos.map(r => (
<div key={r.id}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 12, fontWeight: 500 }}>{r.name}</span>
<button
type="button"
onClick={() => setSelectedRepoForHealth(r)}
style={{
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
background: 'none',
border: 'none',
padding: 0,
color: 'inherit',
textAlign: 'left',
}}
className="hover:text-(--accent) transition"
title={`Click to inspect health breakdown for ${r.name}`}
>
{r.name}
</button>
</div>
<HealthBar score={r.healthScore} />
<HealthBar
score={r.healthScore}
onClick={() => setSelectedRepoForHealth(r)}
isInteractive={true}
title={`Click to inspect health breakdown for ${r.name}`}
ariaLabel={`${r.name}: health score ${r.healthScore} out of 100. Open breakdown.`}
/>
</div>
))}
</div>
Expand All @@ -297,6 +322,12 @@ export default function OverviewPage() {
<NavCard to="/governance" label="Governance" sub="Dead issues, zombie PRs, risky repos, license compliance" />
<NavCard to="/settings" label="Settings" sub="PAT authentication, API quota monitoring, cache management" />
</div>

<RepoHealthDrawer
repo={selectedRepoForHealth}
isOpen={Boolean(selectedRepoForHealth)}
onClose={() => setSelectedRepoForHealth(null)}
/>
</div>
)
}
18 changes: 17 additions & 1 deletion src/pages/RepositoriesPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import EmptyStateCard from '../components/EmptyStateCard'
import { useNavigate } from 'react-router-dom'
import AnalysisBanner from '../components/AnalysisBanner'
import { RepositorySkeleton } from '../components/Orgexplorerskeletons';
import RepoHealthDrawer from '../components/RepoHealthDrawer'

const ACTIVITY_CLASSIFICATIONS = ['All', 'Thriving', 'Active', 'Dormant', 'Hibernating']
const ACTIVITY_COLORS = { Thriving: 'var(--green)', Active: 'var(--blue)', Dormant: 'var(--amber)', Hibernating: 'var(--red)' }
Expand All @@ -21,6 +22,7 @@ export default function RepositoriesPage() {
const [orgFilter, setOrgFilter] = useState('All Organizations')
const [shown, setShown] = useState(20)
const [openInfo, setOpenInfo] = useState(false)
const [selectedRepoForHealth, setSelectedRepoForHealth] = useState(null)
const infoRef = useRef(null)

useEffect(() => {
Expand Down Expand Up @@ -242,7 +244,15 @@ export default function RepositoriesPage() {
<td style={{ padding: '10px 14px', fontSize: 13, color: 'var(--text2)' }}>{r.stargazers_count.toLocaleString()}</td>
<td style={{ padding: '10px 14px', fontSize: 13, color: 'var(--text2)' }}>{r.forks_count.toLocaleString()}</td>
<td style={{ padding: '10px 14px', fontSize: 13, color: r.open_issues_count > 30 ? 'var(--red)' : 'var(--text2)' }}>{r.open_issues_count}</td>
<td style={{ padding: '10px 14px', minWidth: 130 }}><HealthBar score={r.healthScore} /></td>
<td style={{ padding: '10px 14px', minWidth: 130 }}>
<HealthBar
score={r.healthScore}
onClick={() => setSelectedRepoForHealth(r)}
isInteractive={true}
title={`Click to inspect health breakdown for ${r.name}`}
ariaLabel={`${r.name}: health score ${r.healthScore} out of 100. Open breakdown.`}
/>
</td>
<td style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}><Badge text={r.activityClassification} />
<span style={{ fontSize: 11, color: 'var(--text2)' }}>
Expand Down Expand Up @@ -274,6 +284,12 @@ export default function RepositoriesPage() {
/>
</div>
)}

<RepoHealthDrawer
repo={selectedRepoForHealth}
isOpen={Boolean(selectedRepoForHealth)}
onClose={() => setSelectedRepoForHealth(null)}
/>
</div>
)
}
3 changes: 3 additions & 0 deletions src/services/analytics.buildAnalyticalModel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ describe('buildAnalyticalModel', () => {
const result = buildAnalyticalModel(orgs, reposPerOrg, contribsPerRepo, totalReposPerOrg)

expect(result.totalRepos).toHaveLength(2)
const repoAResult = result.totalRepos.find(r => r.name === 'repo-a')
const repoBResult = result.totalRepos.find(r => r.name === 'repo-b')
expect(repoAResult.contributorsFetched).toBe(true)
expect(repoBResult.contributors).toEqual([])
expect(repoBResult.contributorsFetched).toBe(false)
expect(repoBResult.busFactor).toEqual({ factor: 0, risk: 'unknown' })
expect(repoBResult.orgLogin).toBe('org-a')
})
Expand Down
Loading
Loading