diff --git a/.gitignore b/.gitignore index 236cc8a..8d750e3 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ Thumbs.db *.swp desktop.ini +.env.local* diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 9dcc11e..ec0d99f 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,5 +1,6 @@ import ContributionGraph from "@/components/ContributionGraph"; import ContributionHeatmap from "@/components/ContributionHeatmap"; +import RepoContributionDistribution from "@/components/RepoContributionDistribution"; import PRMetrics from "@/components/PRMetrics"; import PRBreakdownChart from "@/components/PRBreakdownChart"; import GoalTracker from "@/components/GoalTracker"; @@ -62,6 +63,7 @@ export default async function DashboardPage() {
+
diff --git a/src/components/RepoContributionDistribution.tsx b/src/components/RepoContributionDistribution.tsx new file mode 100644 index 0000000..82a362c --- /dev/null +++ b/src/components/RepoContributionDistribution.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +type RepoChartItem = { + name: string; + commits: number; + percentage: number; +}; + +type ChartType = "pie" | "bar"; + +type ChartTooltipPayload = { + payload?: RepoChartItem; + value?: number; +}; + +// First color follows the active theme accent. +// Remaining colors are chart-specific categorical hues used to distinguish repositories; +// there are no matching project semantic tokens for every chart slice/bar. +const COLORS = [ + "var(--accent)", + "#22c55e", + "#f97316", + "#06b6d4", + "#ec4899", + "#eab308", + "#8b5cf6", + "#14b8a6", +]; + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null ? (value as Record) : {}; +} + +function getStringValue(record: Record, keys: string[], fallback: string) { + for (const key of keys) { + const value = record[key]; + + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + + return fallback; +} + +function getNumberValue(record: Record, keys: string[]) { + for (const key of keys) { + const value = record[key]; + + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) { + return Number(value); + } + } + + return 0; +} + +function getRepoArray(payload: unknown): unknown[] { + if (Array.isArray(payload)) return payload; + + const record = asRecord(payload); + + if (Array.isArray(record.repos)) return record.repos; + if (Array.isArray(record.data)) return record.data; + if (Array.isArray(record.repositories)) return record.repositories; + + return []; +} + +function normalizeRepos(payload: unknown): RepoChartItem[] { + const repos = getRepoArray(payload); + + const mapped = repos + .map((item) => { + const repo = asRecord(item); + + const name = getStringValue( + repo, + ["name", "repo", "repository", "full_name", "fullName"], + "Unknown repository" + ); + + const commits = getNumberValue(repo, [ + "commits", + "commitCount", + "contributions", + "count", + "totalCommits", + ]); + + return { name, commits }; + }) + .filter((repo) => repo.commits > 0) + .sort((a, b) => b.commits - a.commits) + .slice(0, 8); + + const total = mapped.reduce((sum, repo) => sum + repo.commits, 0); + + return mapped.map((repo) => ({ + ...repo, + percentage: total > 0 ? Number(((repo.commits / total) * 100).toFixed(1)) : 0, + })); +} + +function renderPieLabel(props: unknown) { + const record = asRecord(props); + const payload = asRecord(record.payload); + const percentage = payload.percentage; + + return typeof percentage === "number" ? `${percentage}%` : ""; +} + +function ChartTooltip({ + active, + payload, +}: { + active?: boolean; + payload?: ChartTooltipPayload[]; +}) { + if (!active || !payload?.length || !payload[0]?.payload) { + return null; + } + + const repo = payload[0].payload; + + return ( +
+

{repo.name}

+

{repo.commits} commits

+

{repo.percentage}% contribution

+
+ ); +} + +export default function RepoContributionDistribution({ days = 365 }: { days?: number }) { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [chartType, setChartType] = useState("pie"); + + useEffect(() => { + let cancelled = false; + + async function loadRepos() { + try { + setLoading(true); + setError(""); + + const response = await fetch(`/api/metrics/repos?days=${days}`); + + if (!response.ok) { + throw new Error("Failed to fetch repository metrics."); + } + + const payload: unknown = await response.json(); + const normalized = normalizeRepos(payload); + + if (!cancelled) { + setData(normalized); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to load repository chart."); + setData([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void loadRepos(); + + return () => { + cancelled = true; + }; + }, [days]); + + const totalCommits = useMemo(() => data.reduce((sum, repo) => sum + repo.commits, 0), [data]); + + return ( +
+
+
+

+ Repository Contribution Distribution +

+

+ Repo-wise contribution share based on recent commit activity. +

+
+ +
+ + +
+
+ + {loading ? ( +
+ Loading repository distribution... +
+ ) : error ? ( +
+ {error} +
+ ) : data.length === 0 ? ( +
+ No repository contribution data available yet. +
+ ) : ( + <> +
+
+

Repositories

+

{data.length}

+
+
+

Total commits

+

+ {totalCommits} +

+
+
+

Top repo

+

+ {data[0]?.name} +

+
+
+ +
+ + {chartType === "pie" ? ( + + + {data.map((_, index) => ( + + ))} + + } /> + + ) : ( + + + + + } /> + + {data.map((_, index) => ( + + ))} + + + )} + +
+ + )} +
+ ); +}