From a6566a0d3abb586dec5ba21fcba53729e9350fd3 Mon Sep 17 00:00:00 2001 From: singhanurag0317-bit Date: Mon, 8 Jun 2026 08:40:43 +0000 Subject: [PATCH] feat(repo-health): add interactive Repository Health Explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #398 Problem: repo health scores were hidden in a tooltip. Users couldn't understand why a repo scored low or what signals to improve. Solution: RepoHealthExplorer.tsx — interactive modal with: - SVG radial gauge ring (score + letter grade A/B/C, animated) - Per-metric breakdown cards: commit freq, PR merge rate, PR turnaround, open issues, recency — with animated progress bars + actionable tips - Stacked score distribution bar - Colour-coded recommendations engine (ok / warn / critical) - Trend indicator (Improving / Stable / Declining) - Keyboard-accessible (Escape-to-close, aria-modal, aria-label) - Zero new API calls — uses existing scoring fns from repo-health.ts TopRepos.tsx: health badge click now opens RepoHealthExplorer modal. --- src/components/RepoHealthExplorer.tsx | 240 ++++++++++++++++++++++++++ src/components/TopRepos.tsx | 8 + 2 files changed, 248 insertions(+) create mode 100644 src/components/RepoHealthExplorer.tsx diff --git a/src/components/RepoHealthExplorer.tsx b/src/components/RepoHealthExplorer.tsx new file mode 100644 index 00000000..4f5e56db --- /dev/null +++ b/src/components/RepoHealthExplorer.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { memo, useMemo } from "react"; +import type { RepoHealthScore } from "@/types/repo-health"; +import { + scoreCommitFrequency, + scorePrMergeRate, + scoreAvgPrOpenTimeHours, + scoreOpenIssuesCount, + scoreDaysSinceLastCommit, +} from "@/lib/repo-health"; + +interface MetricConfig { + key: string; + label: string; + score: number; + maxScore: number; + rawValue: string; + tip: string; + weight: string; +} + +interface RepoHealthExplorerProps { + health: RepoHealthScore; + isOpen: boolean; + onClose: () => void; +} + +function GradeRing({ score, grade }: { score: number; grade: string }) { + const r = 36; + const circ = 2 * Math.PI * r; + const offset = circ - (score / 100) * circ; + const color = + grade === "green" ? "var(--success)" + : grade === "yellow" ? "var(--warning)" + : "var(--destructive)"; + const letter = grade === "green" ? "A" : grade === "yellow" ? "B" : "C"; + return ( +
+ +
+ {score} + Grade {letter} +
+
+ ); +} + +function MetricBar({ metric }: { metric: MetricConfig }) { + const pct = Math.round((metric.score / metric.maxScore) * 100); + const barClass = pct >= 70 ? "bg-[var(--success)]" : pct >= 40 ? "bg-[var(--warning)]" : "bg-[var(--destructive)]"; + return ( +
+
+
+

{metric.label}

+

{metric.rawValue}

+
+
+ {metric.score.toFixed(0)} +  / {metric.maxScore} +

{metric.weight}

+
+
+
+
+
+ {metric.tip &&

{metric.tip}

} +
+ ); +} + +function RecBadge({ text, level }: { text: string; level: "ok" | "warn" | "critical" }) { + const cls: Record = { + ok: "bg-[var(--success)]/10 text-[var(--success)] border-[var(--success)]/20", + warn: "bg-[var(--warning)]/10 text-[var(--warning)] border-[var(--warning)]/20", + critical: "bg-[var(--destructive)]/10 text-[var(--destructive)] border-[var(--destructive)]/20", + }; + const prefix: Record = { ok: "✅", warn: "🟡", critical: "🔴" }; + return ( +
+ {prefix[level]} {text} +
+ ); +} + +const RepoHealthExplorer = memo(function RepoHealthExplorer({ health, isOpen, onClose }: RepoHealthExplorerProps) { + const s = health.signals; + const shortName = health.repo.split("/")[1] ?? health.repo; + + const metrics: MetricConfig[] = useMemo(() => [ + { + key: "commits", label: "Commit Frequency", + score: scoreCommitFrequency(s.commitFrequency), maxScore: 25, + rawValue: `${s.commitFrequency} commit${s.commitFrequency !== 1 ? "s" : ""} in last 30 days`, + tip: s.commitFrequency < 5 ? "Low activity. Aim for 10+ commits/month." + : s.commitFrequency < 10 ? "Moderate. 10+ commits/month maximizes this score." + : "Great commit frequency – repository is actively developed.", + weight: "25 pts", + }, + { + key: "pr-merge", label: "PR Merge Rate", + score: scorePrMergeRate(s.prMergeRate), maxScore: 25, + rawValue: `${Math.round(s.prMergeRate * 100)}% of opened PRs merged`, + tip: s.prMergeRate < 0.4 ? "Many PRs going unmerged. Review stale PRs." + : s.prMergeRate < 0.7 ? "Decent merge rate. Aim for 70%+." + : "Excellent merge rate – contributors' work integrated effectively.", + weight: "25 pts", + }, + { + key: "pr-time", label: "PR Turnaround", + score: scoreAvgPrOpenTimeHours(s.avgPrOpenTimeHours), maxScore: 20, + rawValue: s.avgPrOpenTimeHours === 0 ? "No closed PRs yet" : `Avg ${Math.round(s.avgPrOpenTimeHours)}h open before close`, + tip: s.avgPrOpenTimeHours > 168 ? "PRs open over a week. Enable review reminders." + : s.avgPrOpenTimeHours > 48 ? "PRs take 2-7 days. Target under 24h." + : "Fast PR turnaround – review loop is tight.", + weight: "20 pts", + }, + { + key: "issues", label: "Open Issue Load", + score: scoreOpenIssuesCount(s.openIssuesCount), maxScore: 15, + rawValue: `${s.openIssuesCount} open issue${s.openIssuesCount !== 1 ? "s" : ""}`, + tip: s.openIssuesCount >= 20 ? "Very high backlog. Triage stale issues." + : s.openIssuesCount >= 10 ? "Backlog growing. Keep below 10." + : "Healthy issue backlog.", + weight: "15 pts", + }, + { + key: "recency", label: "Recent Activity", + score: scoreDaysSinceLastCommit(s.daysSinceLastCommit), maxScore: 15, + rawValue: `Last commit ${s.daysSinceLastCommit} day${s.daysSinceLastCommit !== 1 ? "s" : ""} ago`, + tip: s.daysSinceLastCommit > 30 ? `No commits in ${s.daysSinceLastCommit} days. Revive activity.` + : s.daysSinceLastCommit > 7 ? "Activity slowed. Commit within 7 days for max score." + : "Repository is actively maintained.", + weight: "15 pts", + }, + ], [s]); + + const recs = useMemo(() => { + const list: Array<{ text: string; level: "ok" | "warn" | "critical" }> = []; + if (s.commitFrequency < 3) list.push({ level: "critical", text: `Only ${s.commitFrequency} commits in 30 days – repository may be going stale.` }); + else if (s.commitFrequency < 10) list.push({ level: "warn", text: `${s.commitFrequency} commits/month. Aim for 10+ to show sustained development.` }); + else list.push({ level: "ok", text: "Commit frequency is healthy." }); + if (s.prMergeRate < 0.4) list.push({ level: "critical", text: `PR merge rate is only ${Math.round(s.prMergeRate * 100)}%. Merge or close stale PRs.` }); + else if (s.prMergeRate < 0.7) list.push({ level: "warn", text: `PR merge rate is ${Math.round(s.prMergeRate * 100)}%. Aim for 70%+.` }); + if (s.avgPrOpenTimeHours > 168) list.push({ level: "critical", text: `Average PR open for ${Math.round(s.avgPrOpenTimeHours / 24)} days. Enable review reminders.` }); + else if (s.avgPrOpenTimeHours > 48) list.push({ level: "warn", text: `Avg PR turnaround ${Math.round(s.avgPrOpenTimeHours)}h. Target under 24h.` }); + if (s.openIssuesCount >= 20) list.push({ level: "critical", text: `${s.openIssuesCount} open issues is very high. Triage your backlog.` }); + else if (s.openIssuesCount >= 10) list.push({ level: "warn", text: `${s.openIssuesCount} open issues. Keep below 10.` }); + if (s.daysSinceLastCommit > 30) list.push({ level: "critical", text: `No commits in ${s.daysSinceLastCommit} days – repository may be inactive.` }); + else if (s.daysSinceLastCommit > 7) list.push({ level: "warn", text: `Last commit ${s.daysSinceLastCommit} days ago. Aim for weekly activity.` }); + return list; + }, [s]); + + const trend = health.score >= 70 ? "▲ Improving" : health.score >= 40 ? "→ Stable" : "▼ Declining"; + const trendColor = health.score >= 70 ? "text-[var(--success)]" : health.score >= 40 ? "text-[var(--warning)]" : "text-[var(--destructive)]"; + + if (!isOpen) return null; + + return ( +
{ if (e.key === "Escape") onClose(); }}> +