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
178 changes: 100 additions & 78 deletions src/components/ContributionGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useAccount } from "@/components/AccountContext";
import {
BarChart,
Expand Down Expand Up @@ -43,35 +43,38 @@ export default function ContributionGraph() {
const [minutesAgo, setMinutesAgo] = useState(0);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const fetchGraph = useCallback(async () => {
setLoading(true);
setError(null);

const accountParam =
selectedAccount !== null
? `&accountId=${encodeURIComponent(selectedAccount)}`
: "";
fetch(`/api/metrics/contributions?days=${days}${accountParam}`)
.then((r) => {
if (!r.ok) throw new Error("API error");
return r.json();
})
.then((res: { data: Record<string, number> }) => {
const sorted = Object.entries(res.data ?? {})
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, commits]) => ({ day, commits }));

setData(sorted);
})
.catch(() => {
setError("Failed to load contribution data.");
})
.finally(() => {
setLoading(false);
setLastUpdated(new Date());
setMinutesAgo(0);
});

try {
const response = await fetch(`/api/metrics/contributions?days=${days}${accountParam}`);
if (!response.ok) throw new Error("API error");

const res = (await response.json()) as { data: Record<string, number> };
const sorted = Object.entries(res.data ?? {})
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, commits]) => ({ day, commits }));

setData(sorted);
setLastUpdated(new Date());
setMinutesAgo(0);
} catch {
setError("Failed to load contribution data.");
} finally {
setLoading(false);
}
}, [days, selectedAccount]);

useEffect(() => {
void fetchGraph();
}, [fetchGraph]);

useEffect(() => {
if (!lastUpdated) return;
const interval = setInterval(() => {
Expand All @@ -82,13 +85,24 @@ export default function ContributionGraph() {
}, [lastUpdated]);

return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<div className="flex flex-wrap items-center justify-between mb-4 gap-2">
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm" aria-busy={loading}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-[var(--card-foreground)]">
Commit Activity
</h2>

<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={fetchGraph}
disabled={loading}
aria-label="Refresh Commit Activity"
className="flex h-8 w-8 items-center justify-center rounded-md text-[var(--muted-foreground)] transition-colors hover:bg-[var(--control)] hover:text-[var(--card-foreground)] disabled:cursor-not-allowed disabled:opacity-60"
>
<span className={loading ? "inline-block animate-spin" : "inline-block"} aria-hidden="true">
</span>
</button>

<div className="flex gap-1 rounded-lg bg-[var(--control)] p-1">
{RANGES.map((r) => (
Expand Down Expand Up @@ -133,9 +147,9 @@ export default function ContributionGraph() {
</div>
</div>

{loading ? (
{loading && data.length === 0 ? (
<div className="h-[200px] rounded bg-[var(--card-muted)] animate-pulse" />
) : error ? (
) : error && data.length === 0 ? (
<div className="flex h-[200px] items-center rounded-lg border border-red-500/30 bg-red-500/10 px-4">
<p className="text-sm text-red-400">
{error} Please try refreshing.
Expand All @@ -146,59 +160,67 @@ export default function ContributionGraph() {
No commits in the last {days} days.
</p>
) : (
<ResponsiveContainer width="100%" height={200}>
{chartType === "bar" ? (
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="day" hide />
<YAxis stroke="var(--muted-foreground)" allowDecimals={false} />
<Tooltip
contentStyle={{
background: "var(--tooltip)",
color: "var(--tooltip-foreground)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
labelStyle={{
color: "var(--tooltip-foreground)",
fontSize: "12px",
}}
cursor={{ fill: "var(--card-muted)" }}
/>
<Bar
dataKey="commits"
fill="var(--accent)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
) : (
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="day" hide />
<YAxis stroke="var(--muted-foreground)" allowDecimals={false} />
<Tooltip
contentStyle={{
background: "var(--tooltip)",
color: "var(--tooltip-foreground)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
labelStyle={{
color: "var(--tooltip-foreground)",
fontSize: "12px",
}}
cursor={{ fill: "var(--card-muted)" }}
/>
<Line
type="monotone"
dataKey="commits"
stroke="var(--accent)"
strokeWidth={2}
dot={false}
/>
</LineChart>
<>
<ResponsiveContainer width="100%" height={200}>
{chartType === "bar" ? (
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="day" hide />
<YAxis stroke="var(--muted-foreground)" allowDecimals={false} />
<Tooltip
contentStyle={{
background: "var(--tooltip)",
color: "var(--tooltip-foreground)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
labelStyle={{
color: "var(--tooltip-foreground)",
fontSize: "12px",
}}
cursor={{ fill: "var(--card-muted)" }}
/>
<Bar
dataKey="commits"
fill="var(--accent)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
) : (
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="day" hide />
<YAxis stroke="var(--muted-foreground)" allowDecimals={false} />
<Tooltip
contentStyle={{
background: "var(--tooltip)",
color: "var(--tooltip-foreground)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
labelStyle={{
color: "var(--tooltip-foreground)",
fontSize: "12px",
}}
cursor={{ fill: "var(--card-muted)" }}
/>
<Line
type="monotone"
dataKey="commits"
stroke="var(--accent)"
strokeWidth={2}
dot={false}
/>
</LineChart>
)}
</ResponsiveContainer>

{error && (
<p className="mt-3 text-sm text-red-400">
{error} Showing the last successful data.
</p>
)}
</ResponsiveContainer>
</>
)}
{lastUpdated && (
<p className="mt-2 text-right text-xs text-[var(--muted-foreground)]">
Expand Down
21 changes: 18 additions & 3 deletions src/components/PRMetrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,25 @@ export default function PRMetrics() {
]
: [];

const showSkeleton = loading && !metrics;

return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-[var(--card-foreground)]">PR Analytics</h2>
{loading ? (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm" aria-busy={loading}>
<div className="mb-4 flex items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-[var(--card-foreground)]">PR Analytics</h2>
<button
type="button"
onClick={fetchMetrics}
disabled={loading}
aria-label="Refresh PR Analytics"
className="flex h-8 w-8 items-center justify-center rounded-md text-[var(--muted-foreground)] transition-colors hover:bg-[var(--control)] hover:text-[var(--card-foreground)] disabled:cursor-not-allowed disabled:opacity-60"
>
<span className={loading ? "inline-block animate-spin" : "inline-block"} aria-hidden="true">
</span>
</button>
</div>
{showSkeleton ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div
Expand Down
Loading
Loading