307 lines
12 KiB
TypeScript
307 lines
12 KiB
TypeScript
"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, 1–5 → 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>
|
||
);
|
||
}
|