Files
kandev/apps/web/components/github/pr-stats.tsx
T
João Salavisa 0e82887ef6 feat(i18n): localize Settings → Integrations → GitHub connection and auth (#2155)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:16:26 +02:00

182 lines
5.2 KiB
TypeScript

"use client";
import { useEffect, useReducer } from "react";
import {
IconGitPullRequest,
IconEye,
IconMessage,
IconCheck,
IconClock,
} from "@tabler/icons-react";
import { Card, CardContent } from "@kandev/ui/card";
import { Spinner } from "@kandev/ui/spinner";
import { fetchGitHubStats } from "@/lib/api/domains/github-api";
import type { PRStats } from "@/lib/types/github";
import { useTranslation } from "react-i18next";
type StatCardProps = {
icon: React.ReactNode;
label: string;
value: string | number;
subtext?: string;
};
function StatCard({ icon, label, value, subtext }: StatCardProps) {
return (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="text-muted-foreground">{icon}</div>
<div>
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
{subtext && <p className="text-[10px] text-muted-foreground mt-0.5">{subtext}</p>}
</div>
</div>
</CardContent>
</Card>
);
}
function formatHours(hours: number): string {
if (hours < 1) return "< 1h";
if (hours < 24) return `${Math.round(hours)}h`;
const days = Math.round(hours / 24);
return `${days}d`;
}
function formatPercent(rate: number): string {
return `${Math.round(rate * 100)}%`;
}
type StatsState = { stats: PRStats | null; loading: boolean };
type StatsAction = { type: "fetch" } | { type: "done"; stats: PRStats | null };
function statsReducer(state: StatsState, action: StatsAction): StatsState {
switch (action.type) {
case "fetch":
return { ...state, loading: true };
case "done":
return { stats: action.stats, loading: false };
}
}
function usePRStats(workspaceId: string | null) {
const [state, dispatch] = useReducer(statsReducer, { stats: null, loading: true });
useEffect(() => {
let cancelled = false;
dispatch({ type: "fetch" });
fetchGitHubStats(workspaceId ? { workspace_id: workspaceId } : {}, { cache: "no-store" })
.then((data) => {
if (!cancelled) dispatch({ type: "done", stats: data });
})
.catch(() => {
if (!cancelled) dispatch({ type: "done", stats: null });
});
return () => {
cancelled = true;
};
}, [workspaceId]);
return state;
}
function StatsGrid({ stats }: { stats: PRStats }) {
const { t } = useTranslation();
return (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
<StatCard
icon={<IconGitPullRequest className="h-5 w-5" />}
label={t("github:prsCreated")}
value={stats.total_prs_created}
/>
<StatCard
icon={<IconEye className="h-5 w-5" />}
label={t("github:prsReviewed")}
value={stats.total_prs_reviewed}
/>
<StatCard
icon={<IconMessage className="h-5 w-5" />}
label={t("github:comments")}
value={stats.total_comments}
/>
<StatCard
icon={<IconCheck className="h-5 w-5" />}
label={t("github:ciPassRate")}
value={formatPercent(stats.ci_pass_rate)}
/>
<StatCard
icon={<IconCheck className="h-5 w-5" />}
label={t("github:approvalRate")}
value={formatPercent(stats.approval_rate)}
/>
<StatCard
icon={<IconClock className="h-5 w-5" />}
label={t("github:avgTimeToMerge")}
value={formatHours(stats.avg_time_to_merge_hours)}
/>
</div>
);
}
function PRsByDayChart({ prsByDay }: { prsByDay: PRStats["prs_by_day"] }) {
const { t } = useTranslation();
if (!prsByDay || prsByDay.length === 0) return null;
const maxCount = Math.max(...prsByDay.map((d) => d.count), 1);
return (
<Card>
<CardContent className="p-4">
<h4 className="text-sm font-medium mb-3">{t("github:prsByDay")}</h4>
<div className="flex items-end gap-1 h-24">
{prsByDay.map((day) => {
const height = Math.max((day.count / maxCount) * 100, 4);
return (
<div
key={day.date}
className="flex-1 bg-primary/20 hover:bg-primary/40 rounded-t transition-colors"
style={{ height: `${height}%` }}
title={t("github:prsOnDay", { date: day.date, count: day.count })}
/>
);
})}
</div>
<div className="flex justify-between mt-1">
<span className="text-[10px] text-muted-foreground">{prsByDay[0]?.date}</span>
<span className="text-[10px] text-muted-foreground">
{prsByDay[prsByDay.length - 1]?.date}
</span>
</div>
</CardContent>
</Card>
);
}
export function PRStatsPanel({ workspaceId }: { workspaceId: string | null }) {
const { t } = useTranslation();
const { stats, loading } = usePRStats(workspaceId);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<Spinner className="h-6 w-6" />
</div>
);
}
if (!stats) {
return (
<p className="text-sm text-muted-foreground text-center py-4">
{t("github:noStatsAvailableYet")}
</p>
);
}
return (
<div className="space-y-4">
<StatsGrid stats={stats} />
<PRsByDayChart prsByDay={stats.prs_by_day} />
</div>
);
}