"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 = { 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 = { 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 = { 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 (
); } // ── Card renderer ────────────────────────────────────────────────────────── function MetricCardLink({ card }: { card: MetricCard }) { const Icon = card.icon; return (

{card.title}

{card.value}

{card.caption}

); } // ── 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 (

Git Project Issue Tracking

High-level Forgejo issue health across repositories synced into Pipeline.

Open issues
{/* Warning banner — left accent border, brighter amber */} {error ? (
{error}
) : null}
{isLoading ? Array.from({ length: 4 }).map((_, i) => ) : cards.map((card) => )}
{!isLoading && !error && repositories.length === 0 ? (
No Git Project repositories are configured yet. Metrics will populate after repositories are added and synced.
) : !isLoading && !error && repositoriesSynced === 0 ? (
Git Project repositories are configured, but Pipeline has not synced issue metrics yet.
) : null}
); }