Pipeline/frontend/src/components/git/ForgejoIssueMetricCards.tsx

307 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import type { ComponentType } from "react";
import Link from "next/link";
import {
AlertCircle,
AlertTriangle,
ArrowUpRight,
CheckCircle2,
Clock3,
RefreshCw,
ShieldAlert,
ShieldCheck,
} from "lucide-react";
import type { ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo";
import { formatRelativeTimestamp } from "@/lib/formatters";
import { cn } from "@/lib/utils";
type ForgejoIssueMetricCardsProps = {
metrics: ForgejoIssueMetrics | null;
repositories: ForgejoRepository[];
isLoading?: boolean;
error?: string | null;
};
// Extended tone set — covers every semantic state
type MetricTone = "amber" | "success" | "danger" | "cyan" | "slate" | "muted";
type MetricCard = {
title: string;
value: string;
caption: string;
href: string;
tone: MetricTone;
icon: ComponentType<{ className?: string }>;
};
const numberFormatter = new Intl.NumberFormat("en-US");
const STALE_SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000;
const formatCount = (value: number | null | undefined): string =>
numberFormatter.format(Math.max(0, Math.round(Number(value ?? 0))));
const parseDate = (value: string | null | undefined): Date | null => {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
};
const newestDate = (dates: Date[]): Date | null => {
if (dates.length === 0) return null;
return dates.reduce((latest, date) =>
date.getTime() > latest.getTime() ? date : latest,
);
};
// ── Tone → icon badge (background + text color) ────────────────────────────
const toneIconClasses: Record<MetricTone, string> = {
amber: "border-[color:rgba(245,158,11,0.35)] bg-[color:rgba(245,158,11,0.14)] text-[color:#F59E0B]",
success: "border-[color:rgba(16,185,129,0.35)] bg-[color:rgba(16,185,129,0.14)] text-[color:#10B981]",
danger: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.14)] text-[color:var(--danger)]",
cyan: "border-[color:rgba(6,182,212,0.35)] bg-[color:rgba(6,182,212,0.14)] text-[color:#06B6D4]",
slate: "border-[color:rgba(100,116,139,0.35)] bg-[color:rgba(100,116,139,0.12)] text-[color:#64748B]",
muted: "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
};
// ── Tone → card (full background + border + hover glow) ───────────────────
const toneCardClasses: Record<MetricTone, string> = {
amber: "border-[color:rgba(245,158,11,0.28)] bg-[color:rgba(245,158,11,0.06)] hover:border-[color:rgba(245,158,11,0.55)] hover:shadow-[0_4px_24px_rgba(245,158,11,0.18)]",
success: "border-[color:rgba(16,185,129,0.28)] bg-[color:rgba(16,185,129,0.06)] hover:border-[color:rgba(16,185,129,0.55)] hover:shadow-[0_4px_24px_rgba(16,185,129,0.18)]",
danger: "border-[color:rgba(248,113,113,0.28)] bg-[color:rgba(248,113,113,0.06)] hover:border-[color:rgba(248,113,113,0.55)] hover:shadow-[0_4px_24px_rgba(248,113,113,0.18)]",
cyan: "border-[color:rgba(6,182,212,0.28)] bg-[color:rgba(6,182,212,0.06)] hover:border-[color:rgba(6,182,212,0.55)] hover:shadow-[0_4px_24px_rgba(6,182,212,0.18)]",
slate: "border-[color:rgba(100,116,139,0.22)] bg-[color:rgba(100,116,139,0.05)] hover:border-[color:rgba(100,116,139,0.40)] hover:shadow-[0_4px_16px_rgba(100,116,139,0.12)]",
muted: "border-[color:var(--border)] bg-[color:var(--surface-muted)] hover:border-[color:var(--border-strong)] hover:shadow-sm",
};
// ── Tone → value text color ────────────────────────────────────────────────
const toneValueClasses: Record<MetricTone, string> = {
amber: "text-[color:#F59E0B]",
success: "text-[color:#10B981]",
danger: "text-[color:var(--danger)]",
cyan: "text-[color:#06B6D4]",
slate: "text-[color:#64748B]",
muted: "text-strong",
};
// ── Card builder: Last Sync Health ─────────────────────────────────────────
function buildSyncHealthCard(
metrics: ForgejoIssueMetrics | null,
repositories: ForgejoRepository[],
): MetricCard {
const repositoryCount = repositories.length;
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
(count) => Number(count) > 0,
).length;
const metricSyncDates = Object.values(metrics?.last_sync_timestamps ?? {}).flatMap(
(v) => { const d = parseDate(v); return d ? [d] : []; },
);
const repositorySyncDates = repositories.flatMap(
(r) => { const d = parseDate(r.last_sync_at); return d ? [d] : []; },
);
const latestSync = newestDate(
metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates,
);
const latestSyncAge = latestSync
? Date.now() - latestSync.getTime()
: Number.POSITIVE_INFINITY;
if (repositoryCount === 0) {
return {
title: "Last Sync Health",
value: "No repos",
caption: "Add repositories to track sync status.",
href: "/git-projects/repositories",
tone: "muted",
icon: RefreshCw,
};
}
if (syncErrorCount > 0) {
return {
title: "Last Sync Health",
value: `${formatCount(syncErrorCount)} repo${syncErrorCount === 1 ? "" : "s"}`,
caption: "Repository sync needs attention.",
href: "/git-projects/repositories",
tone: "danger",
icon: ShieldAlert,
};
}
if (!latestSync) {
return {
title: "Last Sync Health",
value: "Waiting",
caption: "Repositories have not synced yet.",
href: "/git-projects/repositories",
tone: "slate",
icon: Clock3,
};
}
if (latestSyncAge > STALE_SYNC_THRESHOLD_MS) {
return {
title: "Last Sync Health",
value: "Stale",
caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`,
href: "/git-projects/repositories",
tone: "amber",
icon: Clock3,
};
}
return {
title: "Last Sync Health",
value: "Healthy",
caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`,
href: "/git-projects/repositories",
tone: "cyan",
icon: ShieldCheck,
};
}
// ── Skeleton ───────────────────────────────────────────────────────────────
function MetricSkeleton() {
return (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4">
<div className="h-4 w-28 animate-pulse rounded bg-[color:var(--surface-strong)]" />
<div className="mt-4 h-8 w-16 animate-pulse rounded bg-[color:var(--surface-strong)]" />
<div className="mt-3 h-3 w-36 animate-pulse rounded bg-[color:var(--surface-strong)]" />
</div>
);
}
// ── Card renderer ──────────────────────────────────────────────────────────
function MetricCardLink({ card }: { card: MetricCard }) {
const Icon = card.icon;
return (
<Link
href={card.href}
className={cn(
"group flex min-w-0 flex-col justify-between rounded-xl border p-4 transition-all duration-200 hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]",
toneCardClasses[card.tone],
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
{card.title}
</p>
<p className={cn("mt-3 break-words font-heading text-3xl font-semibold", toneValueClasses[card.tone])}>
{card.value}
</p>
</div>
<div
className={cn(
"shrink-0 rounded-lg border p-2 transition-transform duration-200 group-hover:scale-110",
toneIconClasses[card.tone],
)}
>
<Icon className="h-4 w-4" />
</div>
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<p className="min-w-0 text-sm text-muted">{card.caption}</p>
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted transition group-hover:text-[color:var(--accent)]" />
</div>
</Link>
);
}
// ── Main export ────────────────────────────────────────────────────────────
export function ForgejoIssueMetricCards({
metrics,
repositories,
isLoading = false,
error,
}: ForgejoIssueMetricCardsProps) {
const openIssues = metrics?.open_issues ?? 0;
const recentlyClosed = metrics?.closed_last_7_days ?? 0;
const staleOpen = metrics?.stale_open_issues ?? 0;
const repositoriesSynced = metrics?.repositories_synced ?? repositories.length;
// Stale: 0 → slate, 15 → amber, >5 → danger
const staleTone: MetricTone =
staleOpen === 0 ? "slate" :
staleOpen <= 5 ? "amber" :
"danger";
const cards: MetricCard[] = [
{
title: "Open Issues",
value: formatCount(openIssues),
caption:
openIssues === 0
? "No open Git Project issues."
: "Review open issues across Git Projects.",
href: "/git-projects/issues?state=open",
tone: openIssues > 0 ? "amber" : "muted",
icon: openIssues > 0 ? AlertCircle : CheckCircle2,
},
{
title: "Recently Closed",
value: formatCount(recentlyClosed),
caption: "Closed in the last 7 days.",
href: "/git-projects/issues?state=closed&recent=7d",
tone: recentlyClosed > 0 ? "success" : "muted",
icon: CheckCircle2,
},
{
title: "Stale Open Issues",
value: formatCount(staleOpen),
caption:
staleOpen === 0
? "Nothing abandoned right now."
: "Open issues without recent movement.",
href: "/git-projects/issues?state=open&stale=1",
tone: staleTone,
icon: staleOpen === 0 ? CheckCircle2 : Clock3,
},
buildSyncHealthCard(metrics, repositories),
];
return (
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-6">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h3 className="text-lg font-semibold text-strong">
Git Project Issue Tracking
</h3>
<p className="mt-1 text-sm text-muted">
High-level Forgejo issue health across repositories synced into Pipeline.
</p>
</div>
<Link
href="/git-projects/issues"
className="inline-flex w-fit items-center gap-1 text-sm font-medium text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
>
Open issues
<ArrowUpRight className="h-4 w-4" />
</Link>
</div>
{/* Warning banner — left accent border, brighter amber */}
{error ? (
<div className="mb-4 flex items-start gap-3 rounded-lg border border-[color:rgba(245,158,11,0.45)] bg-[color:rgba(245,158,11,0.10)] p-3 text-sm text-[color:#F59E0B] [border-left:3px_solid_#F59E0B]">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-[color:#F59E0B]" />
<span className="font-medium">{error}</span>
</div>
) : null}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{isLoading
? Array.from({ length: 4 }).map((_, i) => <MetricSkeleton key={i} />)
: cards.map((card) => <MetricCardLink key={card.title} card={card} />)}
</div>
{!isLoading && !error && repositories.length === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
No Git Project repositories are configured yet. Metrics will populate after repositories are added and synced.
</div>
) : !isLoading && !error && repositoriesSynced === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
Git Project repositories are configured, but Pipeline has not synced issue metrics yet.
</div>
) : null}
</section>
);
}