feat(forgejo): enhance repository selection and sync functionality in metrics cards

This commit is contained in:
null 2026-05-22 21:25:41 -05:00
parent 9a21ce41d4
commit 7a29aaac14
2 changed files with 233 additions and 33 deletions

View File

@ -2,7 +2,13 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { type KeyboardEvent, type MouseEvent, useMemo } from "react"; import {
type KeyboardEvent,
type MouseEvent,
useEffect,
useMemo,
useState,
} from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@ -75,6 +81,7 @@ import {
getForgejoLastPush, getForgejoLastPush,
getForgejoMetrics, getForgejoMetrics,
getForgejoRepositories, getForgejoRepositories,
syncRepository,
type ForgejoHeatmapDay, type ForgejoHeatmapDay,
type ForgejoIssueMetrics, type ForgejoIssueMetrics,
type ForgejoRepository, type ForgejoRepository,
@ -119,6 +126,7 @@ type GatewaySnapshot = GatewayTarget & {
const DASH = "—"; const DASH = "—";
const FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS = 5 * 60 * 1000; const FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS = 5 * 60 * 1000;
const ALL_FORGEJO_REPOSITORIES = "all";
const DASHBOARD_RANGE = "7d"; const DASHBOARD_RANGE = "7d";
const DASHBOARD_RANGE_DAYS = 7; const DASHBOARD_RANGE_DAYS = 7;
const DASHBOARD_RANGE_LABEL = "7 days"; const DASHBOARD_RANGE_LABEL = "7 days";
@ -429,6 +437,9 @@ const toSessionSummaries = (
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const [selectedForgejoRepositoryId, setSelectedForgejoRepositoryId] =
useState(ALL_FORGEJO_REPOSITORIES);
const [isRefreshingForgejoSync, setIsRefreshingForgejoSync] = useState(false);
const boardsQuery = useListBoardsApiV1BoardsGet< const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse, listBoardsApiV1BoardsGetResponse,
@ -502,6 +513,24 @@ export default function DashboardPage() {
() => forgejoRepositoriesQuery.data ?? [], () => forgejoRepositoriesQuery.data ?? [],
[forgejoRepositoriesQuery.data], [forgejoRepositoriesQuery.data],
); );
const selectedForgejoRepository = useMemo(
() =>
selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
? null
: forgejoRepositories.find(
(repository) => repository.id === selectedForgejoRepositoryId,
) ?? null,
[forgejoRepositories, selectedForgejoRepositoryId],
);
const scopedForgejoRepositories = useMemo(
() =>
selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
? forgejoRepositories
: selectedForgejoRepository
? [selectedForgejoRepository]
: [],
[forgejoRepositories, selectedForgejoRepository, selectedForgejoRepositoryId],
);
const forgejoOrganizationId = useMemo( const forgejoOrganizationId = useMemo(
() => () =>
forgejoRepositories.find((repository) => repository.organization_id) forgejoRepositories.find((repository) => repository.organization_id)
@ -509,16 +538,42 @@ export default function DashboardPage() {
[forgejoRepositories], [forgejoRepositories],
); );
useEffect(() => {
if (
selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES &&
forgejoRepositories.length > 0 &&
!forgejoRepositories.some(
(repository) => repository.id === selectedForgejoRepositoryId,
)
) {
setSelectedForgejoRepositoryId(ALL_FORGEJO_REPOSITORIES);
}
}, [forgejoRepositories, selectedForgejoRepositoryId]);
const forgejoMetricsQuery = useQuery<ForgejoIssueMetrics | null, Error>({ const forgejoMetricsQuery = useQuery<ForgejoIssueMetrics | null, Error>({
queryKey: ["dashboard", "forgejo", "metrics", forgejoOrganizationId], queryKey: [
"dashboard",
"forgejo",
"metrics",
forgejoOrganizationId,
selectedForgejoRepositoryId,
],
enabled: Boolean( enabled: Boolean(
isSignedIn && isSignedIn &&
!forgejoRepositoriesQuery.isLoading && !forgejoRepositoriesQuery.isLoading &&
!forgejoRepositoriesQuery.error, !forgejoRepositoriesQuery.error &&
(
selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
? forgejoOrganizationId
: selectedForgejoRepository
),
), ),
refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS, refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
refetchOnMount: "always", refetchOnMount: "always",
queryFn: () => { queryFn: () => {
if (selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES) {
return getForgejoMetrics({ repository_id: selectedForgejoRepositoryId });
}
if (!forgejoOrganizationId) return Promise.resolve(null); if (!forgejoOrganizationId) return Promise.resolve(null);
return getForgejoMetrics({ organization_id: forgejoOrganizationId }); return getForgejoMetrics({ organization_id: forgejoOrganizationId });
}, },
@ -551,6 +606,34 @@ export default function DashboardPage() {
}, },
}); });
const handleRefreshForgejoLastSync = async () => {
if (isRefreshingForgejoSync) return;
const repositoriesToSync =
selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
? forgejoRepositories.filter((repository) => repository.active)
: selectedForgejoRepository
? [selectedForgejoRepository]
: [];
setIsRefreshingForgejoSync(true);
try {
for (const repository of repositoriesToSync) {
await syncRepository(repository.id);
}
await Promise.all([
forgejoRepositoriesQuery.refetch(),
forgejoMetricsQuery.refetch(),
forgejoHeatmapQuery.refetch(),
forgejoLastPushQuery.refetch(),
]);
} catch (error) {
console.error("Failed to refresh Forgejo sync status:", error);
} finally {
setIsRefreshingForgejoSync(false);
}
};
const boards = useMemo( const boards = useMemo(
() => () =>
boardsQuery.data?.status === 200 boardsQuery.data?.status === 200
@ -1238,6 +1321,11 @@ export default function DashboardPage() {
<ForgejoIssueMetricCards <ForgejoIssueMetricCards
metrics={forgejoIssueMetrics} metrics={forgejoIssueMetrics}
repositories={forgejoRepositories} repositories={forgejoRepositories}
metricRepositories={scopedForgejoRepositories}
selectedRepositoryId={selectedForgejoRepositoryId}
onSelectedRepositoryChange={setSelectedForgejoRepositoryId}
onRefreshLastSync={handleRefreshForgejoLastSync}
isRefreshingLastSync={isRefreshingForgejoSync}
isLoading={forgejoIssueMetricsLoading} isLoading={forgejoIssueMetricsLoading}
error={forgejoIssueMetricsError} error={forgejoIssueMetricsError}
/> />

View File

@ -15,10 +15,22 @@ import {
import type { ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo"; import type { ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type ForgejoIssueMetricCardsProps = { type ForgejoIssueMetricCardsProps = {
metrics: ForgejoIssueMetrics | null; metrics: ForgejoIssueMetrics | null;
repositories: ForgejoRepository[]; repositories: ForgejoRepository[];
metricRepositories?: ForgejoRepository[];
selectedRepositoryId: string;
onSelectedRepositoryChange: (repositoryId: string) => void;
onRefreshLastSync: () => void;
isRefreshingLastSync?: boolean;
isLoading?: boolean; isLoading?: boolean;
error?: string | null; error?: string | null;
}; };
@ -33,10 +45,16 @@ type MetricCard = {
href: string; href: string;
tone: MetricTone; tone: MetricTone;
icon: ComponentType<{ className?: string }>; icon: ComponentType<{ className?: string }>;
action?: {
label: string;
isLoading?: boolean;
onClick: () => void;
};
}; };
const numberFormatter = new Intl.NumberFormat("en-US"); const numberFormatter = new Intl.NumberFormat("en-US");
const STALE_SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000; const STALE_SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000;
const ALL_REPOSITORIES_VALUE = "all";
const formatCount = (value: number | null | undefined): string => const formatCount = (value: number | null | undefined): string =>
numberFormatter.format(Math.max(0, Math.round(Number(value ?? 0)))); numberFormatter.format(Math.max(0, Math.round(Number(value ?? 0))));
@ -67,6 +85,9 @@ const newestDate = (dates: Date[]): Date | null => {
); );
}; };
const repositoryLabel = (repository: ForgejoRepository): string =>
repository.display_name || `${repository.owner}/${repository.repo}`;
// ── Tone → icon badge (background + text color) ──────────────────────────── // ── Tone → icon badge (background + text color) ────────────────────────────
const toneIconClasses: Record<MetricTone, string> = { 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]", amber: "border-[color:rgba(245,158,11,0.35)] bg-[color:rgba(245,158,11,0.14)] text-[color:#F59E0B]",
@ -102,6 +123,8 @@ function buildSyncHealthCard(
metrics: ForgejoIssueMetrics | null, metrics: ForgejoIssueMetrics | null,
repositories: ForgejoRepository[], repositories: ForgejoRepository[],
nowMs: number | null, nowMs: number | null,
onRefreshLastSync: () => void,
isRefreshingLastSync: boolean,
): MetricCard { ): MetricCard {
const repositoryCount = repositories.length; const repositoryCount = repositories.length;
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter( const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
@ -122,7 +145,7 @@ function buildSyncHealthCard(
if (repositoryCount === 0) { if (repositoryCount === 0) {
return { return {
title: "Last Sync Health", title: "Last Sync",
value: "No repos", value: "No repos",
caption: "Add repositories to track sync status.", caption: "Add repositories to track sync status.",
href: "/git-projects/repositories", href: "/git-projects/repositories",
@ -132,41 +155,61 @@ function buildSyncHealthCard(
} }
if (syncErrorCount > 0) { if (syncErrorCount > 0) {
return { return {
title: "Last Sync Health", title: "Last Sync",
value: `${formatCount(syncErrorCount)} repo${syncErrorCount === 1 ? "" : "s"}`, value: `${formatCount(syncErrorCount)} repo${syncErrorCount === 1 ? "" : "s"}`,
caption: "Repository sync needs attention.", caption: "Repository sync needs attention.",
href: "/git-projects/repositories", href: "/git-projects/repositories",
tone: "danger", tone: "danger",
icon: ShieldAlert, icon: ShieldAlert,
action: {
label: "Refresh Last Sync",
isLoading: isRefreshingLastSync,
onClick: onRefreshLastSync,
},
}; };
} }
if (!latestSync) { if (!latestSync) {
return { return {
title: "Last Sync Health", title: "Last Sync",
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: "slate", tone: "slate",
icon: Clock3, icon: Clock3,
action: {
label: "Refresh Last Sync",
isLoading: isRefreshingLastSync,
onClick: onRefreshLastSync,
},
}; };
} }
if (latestSyncAge > STALE_SYNC_THRESHOLD_MS) { if (latestSyncAge > STALE_SYNC_THRESHOLD_MS) {
return { return {
title: "Last Sync Health", title: "Last Sync",
value: "Stale", value: "Stale",
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`, caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
href: "/git-projects/repositories", href: "/git-projects/repositories",
tone: "amber", tone: "amber",
icon: Clock3, icon: Clock3,
action: {
label: "Refresh Last Sync",
isLoading: isRefreshingLastSync,
onClick: onRefreshLastSync,
},
}; };
} }
return { return {
title: "Last Sync Health", title: "Last Sync",
value: "Healthy", value: "Healthy",
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`, caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
href: "/git-projects/repositories", href: "/git-projects/repositories",
tone: "cyan", tone: "cyan",
icon: ShieldCheck, icon: ShieldCheck,
action: {
label: "Refresh Last Sync",
isLoading: isRefreshingLastSync,
onClick: onRefreshLastSync,
},
}; };
} }
@ -187,14 +230,18 @@ function MetricCardLink({ card }: { card: MetricCard }) {
const valueParts = card.value.split(/(\d[\d,]*)/g); const valueParts = card.value.split(/(\d[\d,]*)/g);
return ( return (
<Link <div
href={card.href}
className={cn( 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)]", "group relative flex min-w-0 flex-col justify-between rounded-xl border p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm",
toneCardClasses[card.tone], toneCardClasses[card.tone],
)} )}
> >
<div className="flex items-start justify-between gap-3"> <Link
href={card.href}
className="absolute inset-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
aria-label={`Open ${card.title}`}
/>
<div className="relative z-10 flex items-start justify-between gap-3 pointer-events-none">
<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}
@ -216,20 +263,43 @@ function MetricCardLink({ card }: { card: MetricCard }) {
)} )}
</p> </p>
</div> </div>
<div <div className="flex shrink-0 items-center gap-1.5 pointer-events-auto">
className={cn( {card.action ? (
"shrink-0 rounded-lg border p-2 transition-transform duration-200 group-hover:scale-110", <button
toneIconClasses[card.tone], type="button"
)} onClick={(event) => {
> event.preventDefault();
<Icon className="h-4 w-4" /> event.stopPropagation();
card.action?.onClick();
}}
disabled={card.action.isLoading}
className={cn(
"rounded-lg border p-2 transition hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] disabled:cursor-not-allowed disabled:opacity-70",
toneIconClasses[card.tone],
)}
title={card.action.label}
aria-label={card.action.label}
>
<RefreshCw
className={cn("h-4 w-4", card.action.isLoading && "animate-spin")}
/>
</button>
) : null}
<div
className={cn(
"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>
</div> </div>
<div className="mt-4 flex items-center justify-between gap-3"> <div className="relative z-10 mt-4 flex items-center justify-between gap-3 pointer-events-none">
<p className="min-w-0 text-sm text-muted">{card.caption}</p> <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)]" /> <ArrowUpRight className="h-4 w-4 shrink-0 text-muted transition group-hover:text-[color:var(--accent)]" />
</div> </div>
</Link> </div>
); );
} }
@ -237,6 +307,11 @@ function MetricCardLink({ card }: { card: MetricCard }) {
export function ForgejoIssueMetricCards({ export function ForgejoIssueMetricCards({
metrics, metrics,
repositories, repositories,
metricRepositories,
selectedRepositoryId,
onSelectedRepositoryChange,
onRefreshLastSync,
isRefreshingLastSync = false,
isLoading = false, isLoading = false,
error, error,
}: ForgejoIssueMetricCardsProps) { }: ForgejoIssueMetricCardsProps) {
@ -251,16 +326,24 @@ export function ForgejoIssueMetricCards({
}; };
}, []); }, []);
const activeRepositories = metricRepositories ?? repositories;
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 = metrics?.repositories_synced ?? repositories.length; const repositoriesSynced = metrics?.repositories_synced ?? activeRepositories.length;
// Stale: 0 → slate, 15 → amber, >5 → danger // Stale: 0 → slate, 15 → amber, >5 → danger
const staleTone: MetricTone = const staleTone: MetricTone =
staleOpen === 0 ? "slate" : staleOpen === 0 ? "slate" :
staleOpen <= 5 ? "amber" : staleOpen <= 5 ? "amber" :
"danger"; "danger";
const issueHref = (params: string): string => {
const repositoryParam =
selectedRepositoryId === ALL_REPOSITORIES_VALUE
? ""
: `&repository_id=${encodeURIComponent(selectedRepositoryId)}`;
return `/git-projects/issues?${params}${repositoryParam}`;
};
const cards: MetricCard[] = [ const cards: MetricCard[] = [
{ {
@ -270,7 +353,7 @@ export function ForgejoIssueMetricCards({
openIssues === 0 openIssues === 0
? "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: issueHref("state=open"),
tone: openIssues > 0 ? "amber" : "muted", tone: openIssues > 0 ? "amber" : "muted",
icon: openIssues > 0 ? AlertCircle : CheckCircle2, icon: openIssues > 0 ? AlertCircle : CheckCircle2,
}, },
@ -278,7 +361,7 @@ export function ForgejoIssueMetricCards({
title: "Recently Closed", title: "Recently Closed",
value: formatCount(recentlyClosed), value: formatCount(recentlyClosed),
caption: "Closed in the last 7 days.", caption: "Closed in the last 7 days.",
href: "/git-projects/issues?state=closed&recent=7d", href: issueHref("state=closed&recent=7d"),
tone: recentlyClosed > 0 ? "success" : "muted", tone: recentlyClosed > 0 ? "success" : "muted",
icon: CheckCircle2, icon: CheckCircle2,
}, },
@ -289,11 +372,17 @@ export function ForgejoIssueMetricCards({
staleOpen === 0 staleOpen === 0
? "Nothing abandoned right now." ? "Nothing abandoned right now."
: "Open issues without recent movement.", : "Open issues without recent movement.",
href: "/git-projects/issues?state=open&stale=1", href: issueHref("state=open&stale=1"),
tone: staleTone, tone: staleTone,
icon: staleOpen === 0 ? CheckCircle2 : Clock3, icon: staleOpen === 0 ? CheckCircle2 : Clock3,
}, },
buildSyncHealthCard(metrics, repositories, nowMs), buildSyncHealthCard(
metrics,
activeRepositories,
nowMs,
onRefreshLastSync,
isRefreshingLastSync,
),
]; ];
return ( return (
@ -307,13 +396,36 @@ export function ForgejoIssueMetricCards({
High-level Forgejo issue health across repositories synced into Pipeline. High-level Forgejo issue health across repositories synced into Pipeline.
</p> </p>
</div> </div>
<Link <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
href="/git-projects/issues" <Select
className="inline-flex w-fit items-center gap-1 text-sm font-medium text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]" value={selectedRepositoryId}
> onValueChange={onSelectedRepositoryChange}
Open issues disabled={repositories.length === 0}
<ArrowUpRight className="h-4 w-4" /> >
</Link> <SelectTrigger className="h-9 w-full min-w-[190px] rounded-lg border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 text-sm shadow-none focus:ring-offset-0 sm:w-[230px]">
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value={ALL_REPOSITORIES_VALUE}>All projects</SelectItem>
{repositories.map((repository) => (
<SelectItem key={repository.id} value={repository.id}>
{repositoryLabel(repository)}
</SelectItem>
))}
</SelectContent>
</Select>
<Link
href={
selectedRepositoryId === ALL_REPOSITORIES_VALUE
? "/git-projects/issues"
: `/git-projects/issues?repository_id=${encodeURIComponent(selectedRepositoryId)}`
}
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>
</div> </div>
{/* Warning banner — left accent border, brighter amber */} {/* Warning banner — left accent border, brighter amber */}