feat(dashboard): enhance metric cards with new tone classes and improve styling

This commit is contained in:
null 2026-05-22 16:17:39 -05:00
parent bbfde53fe9
commit c04ab6ac8b
5 changed files with 244 additions and 179 deletions

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import time as _time
from datetime import timedelta
from typing import TYPE_CHECKING
from uuid import UUID
@ -21,11 +22,54 @@ from app.models.forgejo_connections import ForgejoConnection
from app.models.forgejo_issues import ForgejoIssue
from app.models.forgejo_repositories import ForgejoRepository
from app.schemas.metrics import HeatmapDay, HeatmapResponse, MetricsResponse
from app.services.forgejo_client import get_forgejo_client
from app.services.forgejo_client import ForgejoAPIClient, get_forgejo_client
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
# ---------------------------------------------------------------------------
# Line-stats background cache
# ---------------------------------------------------------------------------
# Key: org_id string → (fetched_at, total_additions, total_deletions, has_data)
# Populated by a fire-and-forget asyncio task so the heatmap endpoint never
# blocks waiting for Forgejo's 202 "still computing" response.
# ---------------------------------------------------------------------------
_line_stats_cache: dict[str, tuple[float, int, int, bool]] = {}
_line_stats_fetching: set[str] = set()
_LINE_STATS_TTL = 300 # seconds before a re-fetch is triggered
async def _bg_fetch_line_stats(
cache_key: str,
# Plain tuples so this task never touches a closed DB session
repos: list[tuple[str, str, str, str | None]], # (owner, repo, base_url, token)
since_ts: float,
) -> None:
"""Background task: fetch per-repo contributor stats and cache the totals."""
async def _one(owner: str, repo: str, base_url: str, token: str | None) -> tuple[int, int, bool]:
try:
async with ForgejoAPIClient(base_url=base_url, token=token) as client:
contributors, has_data = await client.get_contributor_stats(owner, repo)
adds = dels = 0
for contributor in contributors:
for week in contributor.get("weeks", []):
if (week.get("w") or 0) >= since_ts:
adds += week.get("a", 0) or 0
dels += week.get("d", 0) or 0
return adds, dels, has_data
except Exception:
return 0, 0, False
try:
results = await asyncio.gather(*[_one(o, r, bu, tok) for o, r, bu, tok in repos])
total_adds = sum(a for a, _, _ in results)
total_dels = sum(d for _, d, _ in results)
has_data = bool(results) and all(ok for _, _, ok in results)
_line_stats_cache[cache_key] = (_time.monotonic(), total_adds, total_dels, has_data)
finally:
_line_stats_fetching.discard(cache_key)
router = APIRouter(prefix="/forgejo", tags=["forgejo-metrics"])
SESSION_DEP = Depends(get_session)
# Use ORG_MEMBER_DEP directly, not wrapped in Depends again
@ -332,29 +376,38 @@ async def get_forgejo_heatmap(
days_list = [HeatmapDay(date=k, count=v) for k, v in sorted(counts.items())]
max_count = max((d.count for d in days_list), default=0)
# Fetch contributor line stats concurrently from Forgejo API
# Line stats — served from cache; background task refreshes when stale.
# Extract plain values NOW while the DB session is still open.
since_ts = since.timestamp()
cache_key = str(ctx.organization.id)
cached = _line_stats_cache.get(cache_key)
now = _time.monotonic()
async def _repo_line_stats(repo: ForgejoRepository, conn: ForgejoConnection) -> tuple[int, int, bool]:
try:
async with get_forgejo_client(conn) as client:
contributors, has_data = await client.get_contributor_stats(repo.owner, repo.repo)
adds = dels = 0
for contributor in contributors:
for week in contributor.get("weeks", []):
if (week.get("w") or 0) >= since_ts:
adds += week.get("a", 0) or 0
dels += week.get("d", 0) or 0
return adds, dels, has_data
except Exception:
return 0, 0, False
if cache_key not in _line_stats_fetching and (
cached is None or now - cached[0] > _LINE_STATS_TTL
):
# Normalise base_url the same way get_forgejo_client() does, eagerly,
# so the background task never touches a potentially-closed session.
import re as _re
repo_tuples: list[tuple[str, str, str, str | None]] = []
for repo, conn in repos_with_conns:
bu = (conn.base_url or "").rstrip("/")
if "/api/v1" in bu:
m = _re.match(r"(https?://[^/]+)", bu)
bu = m.group(1).rstrip("/") if m else bu
repo_tuples.append((repo.owner, repo.repo, bu, getattr(conn, "token", None)))
line_results = await asyncio.gather(
*[_repo_line_stats(repo, conn) for repo, conn in repos_with_conns]
)
total_additions = sum(a for a, _, _ in line_results)
total_deletions = sum(d for _, d, _ in line_results)
has_line_stats = all(ok for _, _, ok in line_results)
_line_stats_fetching.add(cache_key)
asyncio.create_task(
_bg_fetch_line_stats(cache_key, repo_tuples, since_ts),
name=f"line-stats-{cache_key}",
)
if cached is not None:
_, total_additions, total_deletions, has_line_stats = cached
else:
total_additions = total_deletions = 0
has_line_stats = False
return HeatmapResponse(
days=days_list,

View File

@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { Info } from "lucide-react";
import { toneIcon, type MetricToneKey } from "./tokens";
import { toneIcon, toneCard, type MetricToneKey } from "./tokens";
interface DashboardMetricCardProps {
title: string;
@ -24,7 +24,7 @@ export function DashboardMetricCard({
tone,
}: DashboardMetricCardProps) {
return (
<section className="surface-card rounded-xl p-4 md:p-6 transition hover:-translate-y-0.5 hover:shadow-md">
<section className={`rounded-xl p-4 md:p-6 transition hover:-translate-y-0.5 hover:shadow-md ${toneCard[tone]}`}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-1.5">

View File

@ -41,3 +41,11 @@ export const toneIcon: Record<MetricToneKey, string> = {
warning: "bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]",
danger: "bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
};
/** Card-level background + border tint for metric cards. */
export const toneCard: Record<MetricToneKey, string> = {
accent: "border border-[color:rgba(139,92,246,0.28)] bg-[color:rgba(139,92,246,0.08)]",
success: "border border-[color:rgba(52,211,153,0.28)] bg-[color:rgba(52,211,153,0.07)]",
warning: "border border-[color:rgba(251,191,36,0.28)] bg-[color:rgba(251,191,36,0.07)]",
danger: "border border-[color:rgba(248,113,113,0.28)] bg-[color:rgba(248,113,113,0.07)]",
};

View File

@ -12,43 +12,48 @@ interface ForgejoHeatmapProps {
isLoading?: boolean;
}
// Layout constants — 6-month view with larger cells
const CELL = 13;
const GAP = 3;
const STRIDE = CELL + GAP; // 16px per cell
const CELL = 13;
const GAP = 3;
const STRIDE = CELL + GAP;
const WEEKS = 27;
const LEFT = 28; // width reserved for Mon/Wed/Fri labels
const TOP = 18; // height reserved for month labels
const LEFT = 28;
const TOP = 18;
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
// Violet/purple palette — fixed rgba so they render in SVG regardless of CSS var support
// All fixed rgba — no CSS vars inside SVG fill so every browser renders them
const LEVEL_FILL = [
"rgba(139,92,246,0.07)", // 0 — empty (barely-there grid so cells are visible)
"rgba(139,92,246,0.28)", // 1
"rgba(139,92,246,0.52)", // 2
"rgba(139,92,246,0.75)", // 3
"rgba(139,92,246,1.0)", // 4 full violet-500
"rgba(139,92,246,0.08)", // 0 empty — barely-there grid
"rgba(139,92,246,0.30)", // 1
"rgba(139,92,246,0.54)", // 2
"rgba(139,92,246,0.77)", // 3
"rgba(139,92,246,1.00)", // 4 full violet-500
];
const VIOLET = "rgba(139,92,246,1)";
const GREEN = "rgba(52,211,153,1)";
const RED = "rgba(248,113,113,1)";
const LABEL_CLR = "rgba(139,92,246,0.65)";
function toLevel(count: number): number {
if (count === 0) return 0;
if (count <= 2) return 1;
if (count <= 5) return 2;
if (count <= 9) return 3;
if (count <= 2) return 1;
if (count <= 5) return 2;
if (count <= 9) return 3;
return 4;
}
function isoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
return [
d.getFullYear(),
String(d.getMonth() + 1).padStart(2, "0"),
String(d.getDate()).padStart(2, "0"),
].join("-");
}
function fmtLines(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return n.toLocaleString();
}
@ -57,11 +62,11 @@ export function ForgejoHeatmap({
maxCount: _maxCount,
totalAdditions = 0,
totalDeletions = 0,
hasLineStats = false,
isLoading = false,
hasLineStats = false,
isLoading = false,
}: ForgejoHeatmapProps) {
const { weeks, monthLabels } = useMemo(() => {
const data = new Map<string, number>(days.map((d) => [d.date, d.count]));
const data = new Map(days.map((d) => [d.date, d.count]));
const today = new Date();
today.setHours(0, 0, 0, 0);
@ -79,7 +84,7 @@ export function ForgejoHeatmap({
const week: Cell[] = [];
for (let d = 0; d < 7; d++) {
const dateStr = isoDate(cur);
const month = cur.getMonth();
const month = cur.getMonth();
if (d === 0 && month !== lastMonth) {
monthLabelList.push({ weekIdx: w, label: MONTHS[month] });
lastMonth = month;
@ -94,16 +99,13 @@ export function ForgejoHeatmap({
}, [days]);
const svgW = LEFT + WEEKS * STRIDE;
const svgH = TOP + 7 * STRIDE - GAP;
const svgH = TOP + 7 * STRIDE - GAP;
if (isLoading) {
return (
<div className="space-y-3">
<div
className="animate-pulse rounded"
style={{ width: svgW, height: svgH, background: "var(--surface-strong)" }}
/>
<div className="h-10 animate-pulse rounded" style={{ background: "var(--surface-strong)" }} />
<div className="space-y-4">
<div className="animate-pulse rounded" style={{ width: svgW, height: svgH, background: "rgba(139,92,246,0.08)" }} />
<div className="mx-auto h-8 w-64 animate-pulse rounded-full" style={{ background: "rgba(139,92,246,0.08)" }} />
</div>
);
}
@ -111,49 +113,34 @@ export function ForgejoHeatmap({
const totalEvents = days.reduce((sum, d) => sum + d.count, 0);
return (
<div className="space-y-3">
{/* Heatmap grid */}
<div style={{ overflowX: "auto" }} className="flex justify-center">
<svg
width={svgW}
height={svgH}
aria-label="Issue activity heatmap"
style={{ display: "block" }}
>
<div className="space-y-4">
{/* ── Heatmap ─────────────────────────────────────────────── */}
<div className="flex justify-center" style={{ overflowX: "auto" }}>
<svg width={svgW} height={svgH} aria-label="Issue activity heatmap" style={{ display: "block" }}>
{/* Month labels */}
{monthLabels.map(({ weekIdx, label }) => (
<text
key={`m-${weekIdx}`}
x={LEFT + weekIdx * STRIDE}
y={11}
fontSize={10}
style={{ fill: "var(--text-muted, #9ca3af)" }}
>
<text key={`m-${weekIdx}`} x={LEFT + weekIdx * STRIDE} y={11} fontSize={10} style={{ fill: LABEL_CLR }}>
{label}
</text>
))}
{/* Day-of-week labels */}
{/* Day labels */}
{(["Mon", "Wed", "Fri"] as const).map((label, i) => (
<text
key={label}
x={0}
y={TOP + (i * 2 + 1) * STRIDE + CELL - 2}
fontSize={10}
style={{ fill: "var(--text-muted, #9ca3af)" }}
>
<text key={label} x={0} y={TOP + (i * 2 + 1) * STRIDE + CELL - 2} fontSize={10} style={{ fill: LABEL_CLR }}>
{label}
</text>
))}
{/* Cells — use style.fill so rgba + CSS vars both resolve correctly */}
{/* Cells — style.fill so rgba resolves correctly in SVG */}
{weeks.map((week, wi) =>
week.map((cell, di) =>
cell.future ? null : (
<rect
key={cell.date}
x={LEFT + wi * STRIDE}
y={TOP + di * STRIDE}
y={TOP + di * STRIDE}
width={CELL}
height={CELL}
rx={3}
@ -161,70 +148,69 @@ export function ForgejoHeatmap({
>
<title>
{cell.date}
{cell.count > 0
? `: ${cell.count} event${cell.count !== 1 ? "s" : ""}`
: ": no activity"}
{cell.count > 0 ? `: ${cell.count} event${cell.count !== 1 ? "s" : ""}` : ": no activity"}
</title>
</rect>
),
),
)
)
)}
</svg>
</div>
{/* Legend */}
<div className="flex items-center justify-center gap-1.5 text-xs text-muted">
{/* ── Legend ──────────────────────────────────────────────── */}
<div className="flex items-center justify-center gap-1.5 text-xs" style={{ color: LABEL_CLR }}>
<span>Less</span>
{LEVEL_FILL.map((fill, i) => (
<div
key={i}
style={{ width: CELL, height: CELL, borderRadius: 3, background: fill, flexShrink: 0 }}
/>
<div key={i} style={{ width: CELL, height: CELL, borderRadius: 3, background: fill, flexShrink: 0 }} />
))}
<span>More</span>
</div>
{/* Contributions summary — large violet number, readable label */}
<p className="text-center">
<span
className="text-2xl font-bold tabular-nums"
style={{ color: "rgba(139,92,246,1)" }}
{/* ── Contributions badge ──────────────────────────────────── */}
<div className="flex justify-center">
<div
className="inline-flex items-baseline gap-2 rounded-full px-5 py-2"
style={{
background: "rgba(139,92,246,0.10)",
border: "1px solid rgba(139,92,246,0.25)",
}}
>
{totalEvents.toLocaleString()}
</span>
<span className="ml-1.5 text-sm font-medium text-muted">
contributions across all tracked repositories in the last 6 months
</span>
</p>
<span className="text-2xl font-bold tabular-nums" style={{ color: VIOLET }}>
{totalEvents.toLocaleString()}
</span>
<span className="text-sm font-medium" style={{ color: LABEL_CLR }}>
contributions across all tracked repositories in the last 6 months
</span>
</div>
</div>
{/* Line contribution stats */}
{/* ── Line stats ──────────────────────────────────────────── */}
{hasLineStats ? (
<div className="flex items-center justify-center gap-8 pt-1">
<div className="flex flex-col items-center gap-0.5">
<span
className="text-2xl font-bold tabular-nums"
style={{ color: "rgba(52,211,153,1)" }}
>
<span className="text-2xl font-bold tabular-nums" style={{ color: GREEN }}>
+{fmtLines(totalAdditions)}
</span>
<span className="text-xs font-medium text-muted">lines added</span>
<span className="text-xs font-medium" style={{ color: "rgba(52,211,153,0.70)" }}>
lines added
</span>
</div>
<div className="h-10 w-px" style={{ background: "var(--border)" }} />
<div style={{ width: 1, height: 40, background: "rgba(139,92,246,0.20)" }} />
<div className="flex flex-col items-center gap-0.5">
<span
className="text-2xl font-bold tabular-nums"
style={{ color: "rgba(248,113,113,1)" }}
>
<span className="text-2xl font-bold tabular-nums" style={{ color: RED }}>
-{fmtLines(totalDeletions)}
</span>
<span className="text-xs font-medium text-muted">lines removed</span>
<span className="text-xs font-medium" style={{ color: "rgba(248,113,113,0.70)" }}>
lines removed
</span>
</div>
</div>
) : (
<p className="text-center text-xs text-muted opacity-60">
Line stats syncing with Forgejo
<p className="text-center text-xs" style={{ color: LABEL_CLR, opacity: 0.6 }}>
Line stats syncing with Forgejo will appear on next refresh
</p>
)}
</div>
);
}

View File

@ -3,12 +3,14 @@
import type { ComponentType } from "react";
import Link from "next/link";
import {
AlertCircle,
AlertTriangle,
ArrowUpRight,
CheckCircle2,
CircleDot,
Clock3,
RefreshCw,
ShieldAlert,
ShieldCheck,
} from "lucide-react";
import type { ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo";
@ -22,7 +24,8 @@ type ForgejoIssueMetricCardsProps = {
error?: string | null;
};
type MetricTone = "accent" | "success" | "warning" | "danger" | "muted";
// Extended tone set — covers every semantic state
type MetricTone = "amber" | "success" | "danger" | "cyan" | "slate" | "muted";
type MetricCard = {
title: string;
@ -45,19 +48,6 @@ const parseDate = (value: string | null | undefined): Date | null => {
return Number.isNaN(date.getTime()) ? null : date;
};
const toneClasses: Record<MetricTone, string> = {
accent:
"border-[color:var(--accent)]/30 bg-[color:var(--accent-soft)] text-[color:var(--accent)]",
success:
"border-[color:var(--success)]/30 bg-[color:rgba(52,211,153,0.12)] text-[color:var(--success)]",
warning:
"border-[color:var(--warning)]/30 bg-[color:rgba(251,191,36,0.12)] text-[color:var(--warning)]",
danger:
"border-[color:var(--danger)]/30 bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
muted:
"border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
};
const newestDate = (dates: Date[]): Date | null => {
if (dates.length === 0) return null;
return dates.reduce((latest, date) =>
@ -65,6 +55,37 @@ const newestDate = (dates: Date[]): Date | null => {
);
};
// ── 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[],
@ -73,16 +94,12 @@ function buildSyncHealthCard(
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
(count) => Number(count) > 0,
).length;
const metricSyncDates = Object.values(
metrics?.last_sync_timestamps ?? {},
).flatMap((value) => {
const date = parseDate(value);
return date ? [date] : [];
});
const repositorySyncDates = repositories.flatMap((repository) => {
const date = parseDate(repository.last_sync_at);
return date ? [date] : [];
});
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,
);
@ -100,7 +117,6 @@ function buildSyncHealthCard(
icon: RefreshCw,
};
}
if (syncErrorCount > 0) {
return {
title: "Last Sync Health",
@ -108,42 +124,40 @@ function buildSyncHealthCard(
caption: "Repository sync needs attention.",
href: "/git-projects/repositories",
tone: "danger",
icon: AlertTriangle,
icon: ShieldAlert,
};
}
if (!latestSync) {
return {
title: "Last Sync Health",
value: "Waiting",
caption: "Repositories have not synced yet.",
href: "/git-projects/repositories",
tone: "warning",
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: "warning",
tone: "amber",
icon: Clock3,
};
}
return {
title: "Last Sync Health",
value: "Healthy",
caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`,
href: "/git-projects/repositories",
tone: "success",
icon: CheckCircle2,
tone: "cyan",
icon: ShieldCheck,
};
}
// ── Skeleton ───────────────────────────────────────────────────────────────
function MetricSkeleton() {
return (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4">
@ -154,27 +168,31 @@ function MetricSkeleton() {
);
}
// ── Card renderer ──────────────────────────────────────────────────────────
function MetricCardLink({ card }: { card: MetricCard }) {
const Icon = card.icon;
return (
<Link
href={card.href}
className="group flex min-w-0 flex-col justify-between rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 transition hover:-translate-y-0.5 hover:border-[color:var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
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="mt-3 break-words font-heading text-3xl font-semibold text-strong">
<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 group-hover:scale-105",
toneClasses[card.tone],
"shrink-0 rounded-lg border p-2 transition-transform duration-200 group-hover:scale-110",
toneIconClasses[card.tone],
)}
>
<Icon className="h-4 w-4" />
@ -188,17 +206,24 @@ function MetricCardLink({ card }: { card: MetricCard }) {
);
}
// ── Main export ────────────────────────────────────────────────────────────
export function ForgejoIssueMetricCards({
metrics,
repositories,
isLoading = false,
error,
}: ForgejoIssueMetricCardsProps) {
const openIssues = metrics?.open_issues ?? 0;
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;
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",
@ -208,8 +233,8 @@ export function ForgejoIssueMetricCards({
? "No open Git Project issues."
: "Review open issues across Git Projects.",
href: "/git-projects/issues?state=open",
tone: openIssues > 0 ? "accent" : "muted",
icon: CircleDot,
tone: openIssues > 0 ? "amber" : "muted",
icon: openIssues > 0 ? AlertCircle : CheckCircle2,
},
{
title: "Recently Closed",
@ -224,11 +249,11 @@ export function ForgejoIssueMetricCards({
value: formatCount(staleOpen),
caption:
staleOpen === 0
? "No stale open issues detected."
? "Nothing abandoned right now."
: "Open issues without recent movement.",
href: "/git-projects/issues?state=open&stale=1",
tone: staleOpen > 0 ? "warning" : "success",
icon: Clock3,
tone: staleTone,
icon: staleOpen === 0 ? CheckCircle2 : Clock3,
},
buildSyncHealthCard(metrics, repositories),
];
@ -241,8 +266,7 @@ export function ForgejoIssueMetricCards({
Git Project Issue Tracking
</h3>
<p className="mt-1 text-sm text-muted">
High-level Forgejo issue health across repositories synced into
Pipeline.
High-level Forgejo issue health across repositories synced into Pipeline.
</p>
</div>
<Link
@ -254,35 +278,29 @@ export function ForgejoIssueMetricCards({
</Link>
</div>
{/* Warning banner — left accent border, brighter amber */}
{error ? (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-[color:var(--warning)]/35 bg-[color:rgba(251,191,36,0.12)] p-3 text-sm text-[color:var(--warning)]">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
<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((_, index) => (
<MetricSkeleton key={index} />
))
: cards.map((card) => (
<MetricCardLink key={card.title} card={card} />
))}
? 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.
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.
Git Project repositories are configured, but Pipeline has not synced issue metrics yet.
</div>
) : null}
</section>
);
}