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 from __future__ import annotations
import asyncio import asyncio
import time as _time
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID 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_issues import ForgejoIssue
from app.models.forgejo_repositories import ForgejoRepository from app.models.forgejo_repositories import ForgejoRepository
from app.schemas.metrics import HeatmapDay, HeatmapResponse, MetricsResponse 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: if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession 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"]) router = APIRouter(prefix="/forgejo", tags=["forgejo-metrics"])
SESSION_DEP = Depends(get_session) SESSION_DEP = Depends(get_session)
# Use ORG_MEMBER_DEP directly, not wrapped in Depends again # 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())] 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) 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() 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]: if cache_key not in _line_stats_fetching and (
try: cached is None or now - cached[0] > _LINE_STATS_TTL
async with get_forgejo_client(conn) as client: ):
contributors, has_data = await client.get_contributor_stats(repo.owner, repo.repo) # Normalise base_url the same way get_forgejo_client() does, eagerly,
adds = dels = 0 # so the background task never touches a potentially-closed session.
for contributor in contributors: import re as _re
for week in contributor.get("weeks", []): repo_tuples: list[tuple[str, str, str, str | None]] = []
if (week.get("w") or 0) >= since_ts: for repo, conn in repos_with_conns:
adds += week.get("a", 0) or 0 bu = (conn.base_url or "").rstrip("/")
dels += week.get("d", 0) or 0 if "/api/v1" in bu:
return adds, dels, has_data m = _re.match(r"(https?://[^/]+)", bu)
except Exception: bu = m.group(1).rstrip("/") if m else bu
return 0, 0, False repo_tuples.append((repo.owner, repo.repo, bu, getattr(conn, "token", None)))
line_results = await asyncio.gather( _line_stats_fetching.add(cache_key)
*[_repo_line_stats(repo, conn) for repo, conn in repos_with_conns] asyncio.create_task(
) _bg_fetch_line_stats(cache_key, repo_tuples, since_ts),
total_additions = sum(a for a, _, _ in line_results) name=f"line-stats-{cache_key}",
total_deletions = sum(d for _, d, _ in line_results) )
has_line_stats = all(ok for _, _, ok in line_results)
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( return HeatmapResponse(
days=days_list, days=days_list,

View File

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

View File

@ -3,12 +3,14 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import Link from "next/link"; import Link from "next/link";
import { import {
AlertCircle,
AlertTriangle, AlertTriangle,
ArrowUpRight, ArrowUpRight,
CheckCircle2, CheckCircle2,
CircleDot,
Clock3, Clock3,
RefreshCw, RefreshCw,
ShieldAlert,
ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import type { ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo"; import type { ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo";
@ -22,7 +24,8 @@ type ForgejoIssueMetricCardsProps = {
error?: string | null; 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 = { type MetricCard = {
title: string; title: string;
@ -45,19 +48,6 @@ const parseDate = (value: string | null | undefined): Date | null => {
return Number.isNaN(date.getTime()) ? null : date; 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 => { const newestDate = (dates: Date[]): Date | null => {
if (dates.length === 0) return null; if (dates.length === 0) return null;
return dates.reduce((latest, date) => 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( function buildSyncHealthCard(
metrics: ForgejoIssueMetrics | null, metrics: ForgejoIssueMetrics | null,
repositories: ForgejoRepository[], repositories: ForgejoRepository[],
@ -73,16 +94,12 @@ function buildSyncHealthCard(
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter( const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
(count) => Number(count) > 0, (count) => Number(count) > 0,
).length; ).length;
const metricSyncDates = Object.values( const metricSyncDates = Object.values(metrics?.last_sync_timestamps ?? {}).flatMap(
metrics?.last_sync_timestamps ?? {}, (v) => { const d = parseDate(v); return d ? [d] : []; },
).flatMap((value) => { );
const date = parseDate(value); const repositorySyncDates = repositories.flatMap(
return date ? [date] : []; (r) => { const d = parseDate(r.last_sync_at); return d ? [d] : []; },
}); );
const repositorySyncDates = repositories.flatMap((repository) => {
const date = parseDate(repository.last_sync_at);
return date ? [date] : [];
});
const latestSync = newestDate( const latestSync = newestDate(
metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates, metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates,
); );
@ -100,7 +117,6 @@ function buildSyncHealthCard(
icon: RefreshCw, icon: RefreshCw,
}; };
} }
if (syncErrorCount > 0) { if (syncErrorCount > 0) {
return { return {
title: "Last Sync Health", title: "Last Sync Health",
@ -108,42 +124,40 @@ function buildSyncHealthCard(
caption: "Repository sync needs attention.", caption: "Repository sync needs attention.",
href: "/git-projects/repositories", href: "/git-projects/repositories",
tone: "danger", tone: "danger",
icon: AlertTriangle, icon: ShieldAlert,
}; };
} }
if (!latestSync) { if (!latestSync) {
return { return {
title: "Last Sync Health", title: "Last Sync Health",
value: "Waiting", value: "Waiting",
caption: "Repositories have not synced yet.", caption: "Repositories have not synced yet.",
href: "/git-projects/repositories", href: "/git-projects/repositories",
tone: "warning", tone: "slate",
icon: Clock3, icon: Clock3,
}; };
} }
if (latestSyncAge > STALE_SYNC_THRESHOLD_MS) { if (latestSyncAge > STALE_SYNC_THRESHOLD_MS) {
return { return {
title: "Last Sync Health", title: "Last Sync Health",
value: "Stale", value: "Stale",
caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`, caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`,
href: "/git-projects/repositories", href: "/git-projects/repositories",
tone: "warning", tone: "amber",
icon: Clock3, icon: Clock3,
}; };
} }
return { return {
title: "Last Sync Health", title: "Last Sync Health",
value: "Healthy", value: "Healthy",
caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`, caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`,
href: "/git-projects/repositories", href: "/git-projects/repositories",
tone: "success", tone: "cyan",
icon: CheckCircle2, icon: ShieldCheck,
}; };
} }
// ── Skeleton ───────────────────────────────────────────────────────────────
function MetricSkeleton() { function MetricSkeleton() {
return ( return (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4"> <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 }) { function MetricCardLink({ card }: { card: MetricCard }) {
const Icon = card.icon; const Icon = card.icon;
return ( return (
<Link <Link
href={card.href} 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="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted"> <p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
{card.title} {card.title}
</p> </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} {card.value}
</p> </p>
</div> </div>
<div <div
className={cn( className={cn(
"shrink-0 rounded-lg border p-2 transition group-hover:scale-105", "shrink-0 rounded-lg border p-2 transition-transform duration-200 group-hover:scale-110",
toneClasses[card.tone], toneIconClasses[card.tone],
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
@ -188,17 +206,24 @@ function MetricCardLink({ card }: { card: MetricCard }) {
); );
} }
// ── Main export ────────────────────────────────────────────────────────────
export function ForgejoIssueMetricCards({ export function ForgejoIssueMetricCards({
metrics, metrics,
repositories, repositories,
isLoading = false, isLoading = false,
error, error,
}: ForgejoIssueMetricCardsProps) { }: ForgejoIssueMetricCardsProps) {
const openIssues = metrics?.open_issues ?? 0; const openIssues = metrics?.open_issues ?? 0;
const recentlyClosed = metrics?.closed_last_7_days ?? 0; const recentlyClosed = metrics?.closed_last_7_days ?? 0;
const staleOpen = metrics?.stale_open_issues ?? 0; const staleOpen = metrics?.stale_open_issues ?? 0;
const repositoriesSynced = const repositoriesSynced = metrics?.repositories_synced ?? repositories.length;
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[] = [ const cards: MetricCard[] = [
{ {
title: "Open Issues", title: "Open Issues",
@ -208,8 +233,8 @@ export function ForgejoIssueMetricCards({
? "No open Git Project issues." ? "No open Git Project issues."
: "Review open issues across Git Projects.", : "Review open issues across Git Projects.",
href: "/git-projects/issues?state=open", href: "/git-projects/issues?state=open",
tone: openIssues > 0 ? "accent" : "muted", tone: openIssues > 0 ? "amber" : "muted",
icon: CircleDot, icon: openIssues > 0 ? AlertCircle : CheckCircle2,
}, },
{ {
title: "Recently Closed", title: "Recently Closed",
@ -224,11 +249,11 @@ export function ForgejoIssueMetricCards({
value: formatCount(staleOpen), value: formatCount(staleOpen),
caption: caption:
staleOpen === 0 staleOpen === 0
? "No stale open issues detected." ? "Nothing abandoned right now."
: "Open issues without recent movement.", : "Open issues without recent movement.",
href: "/git-projects/issues?state=open&stale=1", href: "/git-projects/issues?state=open&stale=1",
tone: staleOpen > 0 ? "warning" : "success", tone: staleTone,
icon: Clock3, icon: staleOpen === 0 ? CheckCircle2 : Clock3,
}, },
buildSyncHealthCard(metrics, repositories), buildSyncHealthCard(metrics, repositories),
]; ];
@ -241,8 +266,7 @@ export function ForgejoIssueMetricCards({
Git Project Issue Tracking Git Project Issue Tracking
</h3> </h3>
<p className="mt-1 text-sm text-muted"> <p className="mt-1 text-sm text-muted">
High-level Forgejo issue health across repositories synced into High-level Forgejo issue health across repositories synced into Pipeline.
Pipeline.
</p> </p>
</div> </div>
<Link <Link
@ -254,35 +278,29 @@ export function ForgejoIssueMetricCards({
</Link> </Link>
</div> </div>
{/* Warning banner — left accent border, brighter amber */}
{error ? ( {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)]"> <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" /> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-[color:#F59E0B]" />
<span>{error}</span> <span className="font-medium">{error}</span>
</div> </div>
) : null} ) : null}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{isLoading {isLoading
? Array.from({ length: 4 }).map((_, index) => ( ? Array.from({ length: 4 }).map((_, i) => <MetricSkeleton key={i} />)
<MetricSkeleton key={index} /> : cards.map((card) => <MetricCardLink key={card.title} card={card} />)}
))
: cards.map((card) => (
<MetricCardLink key={card.title} card={card} />
))}
</div> </div>
{!isLoading && !error && repositories.length === 0 ? ( {!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"> <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 No Git Project repositories are configured yet. Metrics will populate after repositories are added and synced.
after repositories are added and synced.
</div> </div>
) : !isLoading && !error && repositoriesSynced === 0 ? ( ) : !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"> <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 Git Project repositories are configured, but Pipeline has not synced issue metrics yet.
issue metrics yet.
</div> </div>
) : null} ) : null}
</section> </section>
); );
} }