Skip to content
Open
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
100 changes: 100 additions & 0 deletions src/components/RepoHealthPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"use client";

import { useEffect } from "react";
import type { RepoHealthScore } from "@/types/repo-health";
interface Props {
health: RepoHealthScore;
isOpen: boolean;
onClose: () => void;
}

function ScoreBar({ score, maxScore }: { score: number; maxScore: number }) {
const pct = Math.round((score / maxScore) * 100);
const color = pct >= 70 ? "bg-[var(--accent)]" : pct >= 40 ? "bg-[var(--warning)]" : "bg-[var(--destructive)]";
return (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--control)]">
<div className={`h-full rounded-full transition-all duration-500 ${color}`} style={{ width: `${pct}%` }} />
</div>
);
}

export default function RepoHealthPanel({ health, isOpen, onClose }: Props) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};

document.addEventListener("keydown", handler);

return () => {
document.removeEventListener("keydown", handler);
};
}, [onClose]);

if (!isOpen) return null;
const s = health.signals;
const shortName = health.repo.split("/")[1] ?? health.repo;

const commitScore = Math.min(20, Math.round((s.commitFrequency / 10) * 20));
const prScore = Math.min(20, Math.round(s.prMergeRate * 25));
const prTimeScore = s.avgPrOpenTimeHours === 0 ? 20 : Math.max(0, Math.round(20 - (s.avgPrOpenTimeHours / 168) * 20));
const issueScore = Math.max(0, Math.round(20 - s.openIssuesCount * 2));
const recencyScore = Math.max(0, Math.round(20 - (s.daysSinceLastCommit / 30) * 20));

const dimensions = [
{ label: "Commit Activity", score: commitScore, tip: commitScore < 10 ? "Aim for at least 10 commits per month." : "Good commit frequency!" },
{ label: "PR Merge Rate", score: prScore, tip: prScore < 10 ? "Review and close stale pull requests." : "Healthy merge rate." },
{ label: "PR Turnaround", score: prTimeScore, tip: prTimeScore < 10 ? "Try to review and merge PRs faster." : "PRs are moving quickly." },
{ label: "Issue Load", score: issueScore, tip: issueScore < 10 ? "Triage and close outdated issues." : "Issue backlog looks manageable." },
{ label: "Recent Activity", score: recencyScore, tip: recencyScore < 10 ? `Last commit was ${s.daysSinceLastCommit} days ago.` : "Repo is actively maintained." },
];

return (
<div
className="fixed inset-0 z-50 flex items-end justify-center sm:items-center"
role="dialog"
aria-modal="true"
aria-labelledby="health-panel-title"
>
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
<div className="relative z-10 w-full max-w-md rounded-t-2xl sm:rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-xl">
<div className="flex items-center justify-between mb-5">
<div>
<h2
id="health-panel-title"
className="text-base font-semibold text-[var(--card-foreground)]"
>
Health Breakdown
</h2>
<p className="text-sm text-[var(--muted-foreground)]">{shortName}</p>
</div>
<div className="flex items-center gap-3">
<span className={`text-2xl font-bold ${health.grade === "green" ? "text-[var(--accent)]" : health.grade === "yellow" ? "text-[var(--warning,#ca8a04)]" : "text-[var(--destructive)]"}`}>
{health.score}
</span>
<button type="button" onClick={onClose} className="rounded-lg p-1.5 text-[var(--muted-foreground)] hover:bg-[var(--control)] transition-colors" aria-label="Close panel">
</button>
</div>
</div>
<div className="space-y-4">
{dimensions.map((dim) => (
<div key={dim.label}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-[var(--card-foreground)]">{dim.label}</span>
<span className="text-[var(--muted-foreground)] tabular-nums">{dim.score}/20</span>
</div>
<ScoreBar score={dim.score} maxScore={20} />
{dim.score < 20 && <p className="mt-1 text-xs text-[var(--muted-foreground)]">{dim.tip}</p>}
</div>
))}
</div>
<p className="mt-5 text-xs text-[var(--muted-foreground)] border-t border-[var(--border)] pt-4">
Score based on activity in the last 30 days. Updates on page refresh.
</p>
</div>
</div>
);
}
19 changes: 15 additions & 4 deletions src/components/TopRepos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import { useAccount } from "@/components/AccountContext";
import type { RepoHealthScore } from "@/types/repo-health";
import RepoHealthPanel from "@/components/RepoHealthPanel";

interface RepoLanguage {
name: string;
Expand Down Expand Up @@ -84,7 +85,7 @@ export default function TopRepos() {
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [pinnedRepos, setPinnedRepos] = useState<string[]>([]);
const [pinError, setPinError] = useState<string | null>(null);

const [activeHealthRepo, setActiveHealthRepo] = useState<string | null>(null);
useEffect(() => {
fetch("/api/user/settings")
.then((r) => r.json())
Expand Down Expand Up @@ -326,12 +327,15 @@ export default function TopRepos() {
{healthLoading ? (
<div className="h-5 w-9 rounded bg-[var(--card-muted)] animate-pulse" />
) : health ? (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${badgeClass}`}
<button
type="button"
onClick={() => setActiveHealthRepo(repo.name)}
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold cursor-pointer ${badgeClass}`}
title={badgeTitle}
aria-label={`View health breakdown for ${shortName}`}
>
{health.score}
</span>
</button>
) : (
<span
className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--control)] px-2 py-0.5 text-xs font-semibold text-[var(--muted-foreground)]"
Expand Down Expand Up @@ -405,6 +409,13 @@ export default function TopRepos() {
{minutesAgo === 0 ? "Updated just now" : `Updated ${minutesAgo} min ago`}
</p>
)}
{activeHealthRepo && healthScores[activeHealthRepo] && (
<RepoHealthPanel
health={healthScores[activeHealthRepo]}
isOpen={true}
onClose={() => setActiveHealthRepo(null)}
/>
)}
</div>
);
}
Loading