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(); }}>
+
+
+ {/* Header */}
+
+
+
Repository Health Explorer
+
{shortName}
+
+
+
+
+
+ {/* Score summary */}
+
+
+
+
Overall Score
+
+ {health.score} / 100
+
+
{trend}
+
+
+
+ {/* Score distribution bar */}
+
+
Score Distribution
+
+ {metrics.map((m) => {
+ const pct = (m.score / 100) * 100;
+ const bg = (m.score / m.maxScore) >= 0.7 ? "bg-[var(--success)]"
+ : (m.score / m.maxScore) >= 0.4 ? "bg-[var(--warning)]"
+ : "bg-[var(--destructive)]";
+ return (
+
+ );
+ })}
+
+
+ {metrics.map((m) => (
+ {m.label.split(" ")[0]}
+ ))}
+
+
+
+ {/* Metric cards */}
+
+
Signal Breakdown
+
{metrics.map((m) => )}
+
+
+ {/* Recommendations */}
+
+
Recommendations
+
{recs.map((rec, i) => )}
+
+
+
+
+ );
+});
+
+export default RepoHealthExplorer;
diff --git a/src/components/TopRepos.tsx b/src/components/TopRepos.tsx
index ceb72553..9065687a 100644
--- a/src/components/TopRepos.tsx
+++ b/src/components/TopRepos.tsx
@@ -258,6 +258,7 @@ export default function TopRepos() {
const [pinnedRepos, setPinnedRepos] = useState([]);
const [pinError, setPinError] = useState(null);
const [activeHealthRepo, setActiveHealthRepo] = useState(null);
+ const [selectedExplorer, setSelectedExplorer] = useState(null);
const [selectedRepoForActivity, setSelectedRepoForActivity] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
@@ -586,6 +587,13 @@ export default function TopRepos() {
)}
{activeHealthRepo && healthScores[activeHealthRepo] && (
setSelectedExplorer(null)}
+ />
+ )}
health={healthScores[activeHealthRepo]}
isOpen={true}
onClose={() => setActiveHealthRepo(null)}