diff --git a/.gitignore b/.gitignore index 1158f089..418741ed 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ out/ # Environment files — NEVER commit these .env .env.local +.env.local* .env.production .env.*.local diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index aac71d79..ee30eaec 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -78,6 +78,14 @@ const ContributionHeatmap = dynamic( { ssr: false, loading: () => }, ); +const RepoContributionDistribution = dynamic( + () => import("@/components/RepoContributionDistribution"), + { + ssr: false, + loading: () => , + }, +); + const PRMetrics = dynamic(() => import("@/components/PRMetrics"), { ssr: false, loading: () => , @@ -173,6 +181,9 @@ export default async function DashboardPage() {
+ }> + + }> diff --git a/src/app/globals.css b/src/app/globals.css index e8556685..198ca546 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -28,6 +28,13 @@ --destructive-foreground: #ffffff; --shadow-soft: 0 12px 30px -20px rgba(37, 99, 235, 0.35); --shadow-medium: 0 18px 35px -24px rgba(37, 99, 235, 0.4); + --chart-2: #22c55e; + --chart-3: #f97316; + --chart-4: #06b6d4; + --chart-5: #ec4899; + --chart-6: #eab308; + --chart-7: #8b5cf6; + --chart-8: #14b8a6; } .dark { @@ -61,6 +68,13 @@ --destructive-foreground: #ffffff; --shadow-soft: 0 16px 34px -24px rgba(2, 6, 23, 0.8); --shadow-medium: 0 24px 45px -28px rgba(2, 6, 23, 0.85); + --chart-2: #4ade80; + --chart-3: #fb923c; + --chart-4: #22d3ee; + --chart-5: #f472b6; + --chart-6: #facc15; + --chart-7: #a78bfa; + --chart-8: #2dd4bf; } html, body { @@ -138,12 +152,32 @@ body { scrollbar-width: thin; scrollbar-color: var(--muted-foreground) transparent; } -.scrollbar-thin::-webkit-scrollbar { width: 6px; height: 6px; } -.scrollbar-thin::-webkit-scrollbar-track { background: transparent; } -.scrollbar-thin::-webkit-scrollbar-thumb { background: var(--muted-foreground); border-radius: 9999px; } -.scrollbar-thin::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } -.dark .scrollbar-thin::-webkit-scrollbar-thumb { background: var(--muted-foreground); } -.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } + +.scrollbar-thin::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.scrollbar-thin::-webkit-scrollbar-track { + background: transparent; +} + +.scrollbar-thin::-webkit-scrollbar-thumb { + background: var(--muted-foreground); + border-radius: 9999px; +} + +.scrollbar-thin::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); +} + +.dark .scrollbar-thin::-webkit-scrollbar-thumb { + background: var(--muted-foreground); +} + +.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); +} @keyframes fadeUp { from { diff --git a/src/components/RepoContributionDistribution.tsx b/src/components/RepoContributionDistribution.tsx new file mode 100644 index 00000000..da04d64a --- /dev/null +++ b/src/components/RepoContributionDistribution.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useTheme } from "./ThemeContext"; +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; +}; + + + +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 { theme } = useTheme(); + const [colors, setColors] = useState([]); + + useEffect(() => { + const style = getComputedStyle(document.documentElement); + const resolvedColors = [ + style.getPropertyValue("--accent").trim() || "var(--accent)", + style.getPropertyValue("--chart-2").trim() || "var(--chart-2)", + style.getPropertyValue("--chart-3").trim() || "var(--chart-3)", + style.getPropertyValue("--chart-4").trim() || "var(--chart-4)", + style.getPropertyValue("--chart-5").trim() || "var(--chart-5)", + style.getPropertyValue("--chart-6").trim() || "var(--chart-6)", + style.getPropertyValue("--chart-7").trim() || "var(--chart-7)", + style.getPropertyValue("--chart-8").trim() || "var(--chart-8)", + ]; + setColors(resolvedColors); + }, [theme]); + + const activeColors = colors.length > 0 ? colors : [ + "var(--accent)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", + "var(--chart-6)", + "var(--chart-7)", + "var(--chart-8)", + ]; + + 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) => ( + + ))} + + + )} + +
+ + )} +
+ ); +}