health stats for Open Issues and Webhook coverage

This commit is contained in:
null 2026-05-24 21:36:00 -05:00
parent 5a1caa8264
commit fc1fa41a28
2 changed files with 577 additions and 96 deletions

View File

@ -16,6 +16,7 @@ import {
GitBranch,
KeyRound,
Link2,
ListChecks,
Loader2,
RefreshCw,
Server,
@ -39,6 +40,7 @@ import {
deleteForgejoConnection,
deleteForgejoRepository,
getForgejoConnections,
getLinkedBoardsForRepository,
getForgejoRepositories,
massImportRepositories,
syncRepository,
@ -58,6 +60,26 @@ type DeleteTarget =
| { type: "connection"; item: ForgejoConnection }
| { type: "repository"; item: ForgejoRepository };
type BoardLink = {
id: string;
name: string;
};
type LastMassImport = {
finishedAt: string;
result: MassImportResponse;
};
type AttentionItem = {
id: string;
title: string;
detail: string;
tone: "warning" | "danger" | "muted";
href?: string;
};
const LAST_MASS_IMPORT_STORAGE_KEY = "pipeline.gitProjects.lastMassImport";
const repositoryName = (repository: ForgejoRepository) =>
repository.display_name || `${repository.owner}/${repository.repo}`;
@ -68,6 +90,24 @@ const formatTimestamp = (value: string | null) => {
return date.toLocaleString();
};
const formatCompactNumber = (value: number) =>
new Intl.NumberFormat(undefined, { notation: "compact" }).format(value);
const summarizeMassImport = (result: MassImportResponse) =>
`${result.total_created} created, ${result.total_updated} updated, ${result.total_stale_closed} closed`;
const isLastMassImport = (value: unknown): value is LastMassImport => {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<LastMassImport>;
return (
typeof candidate.finishedAt === "string" &&
typeof candidate.result?.total_created === "number" &&
typeof candidate.result?.total_updated === "number" &&
typeof candidate.result?.total_stale_closed === "number" &&
Array.isArray(candidate.result?.results)
);
};
function NoticeBanner({ notice }: { notice: Notice }) {
return (
<div
@ -87,6 +127,33 @@ function NoticeBanner({ notice }: { notice: Notice }) {
);
}
function SectionHeader({
icon,
title,
description,
action,
}: {
icon: ReactNode;
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div className="shrink-0 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)]">
{icon}
</div>
<div className="min-w-0">
<h2 className="text-base font-semibold text-strong">{title}</h2>
<p className="mt-1 text-sm text-muted">{description}</p>
</div>
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}
function StatCard({
icon,
label,
@ -118,6 +185,140 @@ function StatCard({
);
}
function AttentionPanel({ items }: { items: AttentionItem[] }) {
return (
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<SectionHeader
icon={<ListChecks className="h-4 w-4" />}
title="Needs Attention"
description="Connection and repository signals that can block fresh issue data."
/>
{items.length === 0 ? (
<div className="mt-4 flex items-start gap-3 rounded-lg border border-[color:rgba(52,211,153,0.28)] bg-[color:rgba(52,211,153,0.08)] p-3 text-sm">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-[color:var(--success)]" />
<div>
<p className="font-medium text-strong">No action needed</p>
<p className="mt-1 text-muted">
No sync errors, missing tokens, archived repos, or webhook gaps
need attention.
</p>
</div>
</div>
) : (
<div className="mt-4 divide-y divide-[color:var(--border)] rounded-lg border border-[color:var(--border)]">
{items.map((item) => (
<div
key={item.id}
className="flex flex-col gap-2 p-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0">
<p className="font-medium text-strong">{item.title}</p>
<p className="mt-1 text-sm text-muted">{item.detail}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={
item.tone === "danger"
? "text-xs font-semibold uppercase tracking-wide text-[color:var(--danger)]"
: item.tone === "warning"
? "text-xs font-semibold uppercase tracking-wide text-[color:var(--warning)]"
: "text-xs font-semibold uppercase tracking-wide text-muted"
}
>
{item.tone}
</span>
{item.href ? (
<Link href={item.href}>
<Button type="button" variant="ghost" size="sm">
Open
</Button>
</Link>
) : null}
</div>
</div>
))}
</div>
)}
</section>
);
}
function LastImportPanel({
lastImport,
onOpen,
}: {
lastImport: LastMassImport | null;
onOpen: () => void;
}) {
return (
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<SectionHeader
icon={<Download className="h-4 w-4" />}
title="Full Import"
description="Run a complete issue pull when webhooks or scheduled sync need a reset."
action={
<Button type="button" variant="outline" size="sm" onClick={onOpen}>
<Download className="h-4 w-4" />
Open
</Button>
}
/>
<div className="mt-4 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">
{lastImport ? (
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-strong">
{summarizeMassImport(lastImport.result)}
</p>
<p className="mt-1 text-xs text-muted">
Finished {formatTimestamp(lastImport.finishedAt)} across{" "}
{lastImport.result.succeeded} repositor
{lastImport.result.succeeded === 1 ? "y" : "ies"}.
{lastImport.result.failed > 0 ? (
<span className="ml-1 text-[color:var(--danger)]">
{lastImport.result.failed} failed.
</span>
) : null}
</p>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg bg-[color:var(--surface)] px-2 py-2">
<p className="text-lg font-semibold text-strong">
{lastImport.result.total_created}
</p>
<p className="text-[11px] uppercase tracking-wide text-muted">
Created
</p>
</div>
<div className="rounded-lg bg-[color:var(--surface)] px-2 py-2">
<p className="text-lg font-semibold text-strong">
{lastImport.result.total_updated}
</p>
<p className="text-[11px] uppercase tracking-wide text-muted">
Updated
</p>
</div>
<div className="rounded-lg bg-[color:var(--surface)] px-2 py-2">
<p className="text-lg font-semibold text-strong">
{lastImport.result.total_stale_closed}
</p>
<p className="text-[11px] uppercase tracking-wide text-muted">
Closed
</p>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted">
No full import has run in this session. Scheduled sync still keeps
active repositories current.
</p>
)}
</div>
</section>
);
}
function CopyButton({ value, label }: { value: string; label?: string }) {
const [copied, setCopied] = useState(false);
@ -160,6 +361,12 @@ export default function GitProjectSettingsPage() {
const [massImportOpen, setMassImportOpen] = useState(false);
const [massImportResult, setMassImportResult] =
useState<MassImportResponse | null>(null);
const [lastMassImport, setLastMassImport] = useState<LastMassImport | null>(
null,
);
const [linkedBoardsByRepository, setLinkedBoardsByRepository] = useState<
Record<string, BoardLink[]>
>({});
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<Notice | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
@ -173,6 +380,32 @@ export default function GitProjectSettingsPage() {
return () => clearTimeout(id);
}, [notice]);
useEffect(() => {
try {
const stored = window.localStorage.getItem(LAST_MASS_IMPORT_STORAGE_KEY);
if (!stored) return;
const parsed: unknown = JSON.parse(stored);
if (isLastMassImport(parsed)) {
setLastMassImport(parsed);
}
} catch {
// Local storage is optional for this summary.
}
}, []);
useEffect(() => {
try {
if (lastMassImport) {
window.localStorage.setItem(
LAST_MASS_IMPORT_STORAGE_KEY,
JSON.stringify(lastMassImport),
);
}
} catch {
// Local storage is optional for this summary.
}
}, [lastMassImport]);
const loadSettings = useCallback(async () => {
try {
setIsLoading(true);
@ -182,6 +415,25 @@ export default function GitProjectSettingsPage() {
]);
setConnections(connectionsData);
setRepositories(repositoriesData);
const boardResults = await Promise.allSettled(
repositoriesData.map(async (repo) => {
const boards = await getLinkedBoardsForRepository(repo.id);
return [repo.id, boards] as const;
}),
);
setLinkedBoardsByRepository(
Object.fromEntries(
boardResults
.filter(
(
result,
): result is PromiseFulfilledResult<
readonly [string, BoardLink[]]
> => result.status === "fulfilled",
)
.map((result) => result.value),
),
);
setError(null);
} catch (err) {
setError(
@ -223,6 +475,88 @@ export default function GitProjectSettingsPage() {
() => repositories.filter((r) => r.last_sync_error).length,
[repositories],
);
const totalOpenIssues = useMemo(
() =>
repositories.reduce((total, repo) => total + repo.open_issues_count, 0),
[repositories],
);
const activeRepositoriesWithWebhooks = useMemo(
() =>
repositories.filter((r) => r.active && r.has_webhook_secret).length,
[repositories],
);
const archivedRepositories = useMemo(
() => repositories.filter((r) => r.is_archived).length,
[repositories],
);
const attentionItems = useMemo<AttentionItem[]>(() => {
const connectionItems = connections.flatMap((connection) => {
const items: AttentionItem[] = [];
if (!connection.active) {
items.push({
id: `connection-inactive-${connection.id}`,
title: `${connection.name} is inactive`,
detail: "Repositories using this connection will not receive fresh issue data.",
tone: "muted",
href: `/git-projects/connections/${connection.id}/edit`,
});
}
if (!connection.has_token) {
items.push({
id: `connection-token-${connection.id}`,
title: `${connection.name} is missing a token`,
detail: "Validation, sync, and imports need a configured provider token.",
tone: "danger",
href: `/git-projects/connections/${connection.id}/edit`,
});
}
return items;
});
const repositoryItems = repositories.flatMap((repo) => {
const name = repositoryName(repo);
const items: AttentionItem[] = [];
if (repo.last_sync_error) {
items.push({
id: `repo-error-${repo.id}`,
title: `${name} has a sync error`,
detail: repo.last_sync_error,
tone: "danger",
href: `/git-projects/repositories/${repo.id}/edit`,
});
}
if (repo.active && !repo.has_webhook_secret) {
items.push({
id: `repo-webhook-${repo.id}`,
title: `${name} has no webhook secret`,
detail: "Webhook setup can receive events, but signature validation needs a stored secret.",
tone: "warning",
href: `/git-projects/repositories/${repo.id}/edit`,
});
}
if (repo.is_archived) {
items.push({
id: `repo-archived-${repo.id}`,
title: `${name} is archived upstream`,
detail: "Archived repositories can stay visible, but they may not need active syncing.",
tone: "muted",
href: `/git-projects/repositories/${repo.id}/edit`,
});
}
if (!repo.active) {
items.push({
id: `repo-inactive-${repo.id}`,
title: `${name} is inactive`,
detail: "Inactive repositories are excluded from Sync All and full imports.",
tone: "muted",
href: `/git-projects/repositories/${repo.id}/edit`,
});
}
return items;
});
return [...connectionItems, ...repositoryItems];
}, [connections, repositories]);
const webhookBaseUrl = useMemo(() => {
try {
@ -310,6 +644,10 @@ export default function GitProjectSettingsPage() {
try {
const result = await massImportRepositories();
setMassImportResult(result);
setLastMassImport({
finishedAt: new Date().toISOString(),
result,
});
await loadSettings();
} catch (err) {
setNotice({
@ -431,7 +769,6 @@ export default function GitProjectSettingsPage() {
variant="outline"
size="sm"
onClick={() => {
setMassImportResult(null);
setMassImportOpen(true);
}}
disabled={isMassImporting || isLoading || activeRepositories === 0}
@ -465,7 +802,7 @@ export default function GitProjectSettingsPage() {
) : null}
{/* Stats */}
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-6">
<StatCard
icon={<Server className="h-4 w-4" />}
label="Connections"
@ -478,6 +815,18 @@ export default function GitProjectSettingsPage() {
value={`${activeRepositories}/${repositories.length}`}
caption="Active tracked repositories."
/>
<StatCard
icon={<CircleDot className="h-4 w-4" />}
label="Open Issues"
value={formatCompactNumber(totalOpenIssues)}
caption="Open issues reported by tracked repositories."
/>
<StatCard
icon={<Webhook className="h-4 w-4" />}
label="Webhooks"
value={`${activeRepositoriesWithWebhooks}/${activeRepositories}`}
caption="Active repositories with webhook secrets."
/>
<StatCard
icon={<RefreshCw className="h-4 w-4" />}
label="Latest Sync"
@ -488,28 +837,33 @@ export default function GitProjectSettingsPage() {
icon={<AlertCircle className="h-4 w-4" />}
label="Sync Errors"
value={String(syncErrorCount)}
caption="Repositories with a recorded sync error."
caption={`Repositories with sync errors; ${archivedRepositories} archived tracked.`}
/>
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.65fr)]">
<AttentionPanel items={attentionItems} />
<LastImportPanel
lastImport={lastMassImport}
onOpen={() => setMassImportOpen(true)}
/>
</div>
{/* Connections */}
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="text-base font-semibold text-strong">
Forgejo Connections
</h2>
<p className="mt-1 text-sm text-muted">
URL and token records used by tracked repositories.
</p>
</div>
<SectionHeader
icon={<Link2 className="h-4 w-4" />}
title="Forgejo Connections"
description="URL and token records used by tracked repositories."
action={
<Link href="/git-projects/connections">
<Button size="sm" variant="outline">
<Link2 className="h-4 w-4" />
Manage Connections
</Button>
</Link>
</div>
}
/>
<div className="mt-4 overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)]">
<ForgejoConnectionsTable
connections={connections}
@ -524,15 +878,11 @@ export default function GitProjectSettingsPage() {
{/* Repositories */}
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="text-base font-semibold text-strong">
Tracked Repositories
</h2>
<p className="mt-1 text-sm text-muted">
Repositories whose issues are cached and shown in Pipeline.
</p>
</div>
<SectionHeader
icon={<GitBranch className="h-4 w-4" />}
title="Tracked Repositories"
description="Repositories whose issues are cached and shown in Pipeline."
action={
<div className="flex flex-wrap gap-2">
<Link href="/git-projects/repositories/new">
<Button size="sm">
@ -547,11 +897,13 @@ export default function GitProjectSettingsPage() {
</Button>
</Link>
</div>
</div>
}
/>
<div className="mt-4 overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)]">
<ForgejoRepositoriesTable
repositories={repositories}
isLoading={isLoading}
linkedBoardsByRepository={linkedBoardsByRepository}
onDelete={(repository) =>
setDeleteTarget({ type: "repository", item: repository })
}
@ -563,28 +915,19 @@ export default function GitProjectSettingsPage() {
{/* Webhook Setup */}
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<div className="flex items-start gap-3 mb-4">
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)] shrink-0">
<Webhook className="h-4 w-4" />
</div>
<div className="min-w-0">
<h2 className="text-base font-semibold text-strong">
Webhook Setup
</h2>
<p className="mt-1 text-sm text-muted">
Configure webhooks in Forgejo to push issue updates to
Pipeline in real time, without waiting for the scheduled sync.
</p>
</div>
</div>
<SectionHeader
icon={<Webhook className="h-4 w-4" />}
title="Webhook Setup"
description="Configure Forgejo webhooks to push issue updates to Pipeline in real time."
/>
{repositories.length === 0 ? (
<p className="text-sm text-muted">
<p className="mt-4 text-sm text-muted">
No repositories tracked yet. Add a repository to see webhook
URLs.
</p>
) : (
<div className="space-y-3">
<div className="mt-4 space-y-3">
<p className="text-xs text-muted">
In each Forgejo repository, go to{" "}
<strong className="text-strong">
@ -673,17 +1016,13 @@ export default function GitProjectSettingsPage() {
{/* Scheduled Sync */}
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<div className="flex items-start gap-3">
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)] shrink-0">
<Clock className="h-4 w-4" />
</div>
<div className="min-w-0">
<h2 className="text-base font-semibold text-strong">
Scheduled Sync
</h2>
<p className="mt-1 text-sm text-muted">
Pipeline runs a background sync for all active repositories
every <strong className="text-strong">60 minutes</strong>.
<SectionHeader
icon={<Clock className="h-4 w-4" />}
title="Scheduled Sync"
description="Pipeline runs a background sync for all active repositories every 60 minutes."
/>
<div className="mt-4 pl-0 sm:pl-11">
<p className="text-sm text-muted">
This keeps issues current without manual syncing or webhooks.
The interval is configured via environment variable and cannot
be changed from the UI.
@ -703,7 +1042,6 @@ export default function GitProjectSettingsPage() {
</span>
</div>
</div>
</div>
</section>
</div>
</DashboardPageLayout>
@ -826,7 +1164,11 @@ export default function GitProjectSettingsPage() {
</tbody>
</table>
</div>
<div className="flex justify-end">
<div className="flex justify-end gap-2">
<Button type="button" onClick={handleMassImport}>
<Download className="h-4 w-4" />
Import Again
</Button>
<Button
type="button"
variant="outline"

View File

@ -11,7 +11,15 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CheckCircle2, GitBranch, Loader2, RefreshCw } from "lucide-react";
import {
Archive,
CheckCircle2,
GitBranch,
KeyRound,
Loader2,
RefreshCw,
Tags,
} from "lucide-react";
import { DataTable } from "@/components/tables/DataTable";
import { cn } from "@/lib/utils";
@ -28,9 +36,18 @@ type RepositorySyncResult = {
total: number;
};
type BoardLink = {
id: string;
name: string;
};
const labelColor = (color: string) =>
color.startsWith("#") ? color : `#${color}`;
interface RepositoriesTableProps {
repositories: ForgejoRepository[];
isLoading: boolean;
linkedBoardsByRepository?: Record<string, BoardLink[]>;
onEdit?: (repository: ForgejoRepository) => void;
onDelete?: (repository: ForgejoRepository) => void;
onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>;
@ -42,6 +59,7 @@ interface RepositoriesTableProps {
export function ForgejoRepositoriesTable({
repositories,
isLoading,
linkedBoardsByRepository = {},
onEdit,
onDelete,
onSync,
@ -51,7 +69,7 @@ export function ForgejoRepositoriesTable({
const _ = onEdit;
const table = useReactTable({
data: repositories,
columns: columns(onSync, onValidate),
columns: columns(onSync, onValidate, linkedBoardsByRepository),
getCoreRowModel: getCoreRowModel(),
});
@ -71,7 +89,7 @@ export function ForgejoRepositoriesTable({
getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`,
onDelete: onDelete ?? undefined,
}}
tableClassName="min-w-[860px] w-full text-left text-sm"
tableClassName="min-w-[1180px] w-full text-left text-sm"
/>
);
}
@ -81,6 +99,7 @@ const columns = (
onValidate?: (
repository: ForgejoRepository,
) => Promise<ForgejoRepositoryValidationResponse>,
linkedBoardsByRepository: Record<string, BoardLink[]> = {},
): ColumnDef<ForgejoRepository>[] => [
{
accessorKey: "displayName",
@ -107,6 +126,11 @@ const columns = (
<span className="block truncate font-mono text-xs text-muted">
{repo.owner}/{repo.repo} {repo.connection?.name}
</span>
{repo.description ? (
<span className="mt-1 block max-w-[300px] truncate text-xs text-muted">
{repo.description}
</span>
) : null}
</div>
);
},
@ -132,11 +156,126 @@ const columns = (
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const isActive = row.original.active;
const repo = row.original;
const isActive = repo.active;
return (
<div className="flex flex-wrap gap-1.5">
<Badge variant={isActive ? "default" : "outline"}>
{isActive ? "Active" : "Inactive"}
</Badge>
{repo.is_archived ? (
<Badge variant="warning" className="gap-1">
<Archive className="h-3 w-3" />
Archived
</Badge>
) : null}
{repo.has_webhook_secret ? (
<Badge variant="success" className="gap-1">
<KeyRound className="h-3 w-3" />
Webhook
</Badge>
) : (
<Badge variant="outline" className="gap-1">
<KeyRound className="h-3 w-3" />
No secret
</Badge>
)}
</div>
);
},
},
{
accessorKey: "openIssues",
header: "Issues",
cell: ({ row }) => (
<div className="min-w-[90px]">
<span className="block text-lg font-semibold text-strong">
{row.original.open_issues_count}
</span>
<span className="text-xs text-muted">open upstream</span>
</div>
),
},
{
accessorKey: "metadata",
header: "Metadata",
cell: ({ row }) => {
const repo = row.original;
const visibleTopics = repo.topics.slice(0, 2);
const hiddenTopics = Math.max(
repo.topics.length - visibleTopics.length,
0,
);
const visibleLabels = repo.labels.slice(0, 2);
const hiddenLabels = Math.max(
repo.labels.length - visibleLabels.length,
0,
);
return (
<div className="min-w-[220px] space-y-2">
<span className="block truncate font-mono text-xs text-muted">
default: {repo.default_branch || "unknown"}
</span>
<div className="flex flex-wrap gap-1.5">
{visibleTopics.map((topic) => (
<Badge key={topic} variant="accent" className="gap-1 normal-case">
<Tags className="h-3 w-3" />
{topic}
</Badge>
))}
{hiddenTopics > 0 ? (
<Badge variant="outline">+{hiddenTopics} topics</Badge>
) : null}
{visibleTopics.length === 0 ? (
<span className="text-xs text-muted">No topics</span>
) : null}
</div>
<div className="flex flex-wrap gap-1.5">
{visibleLabels.map((label) => (
<span
key={`${label.id ?? label.name}-${label.name}`}
className="inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] px-2 py-0.5 text-xs text-strong"
title={label.description || label.name}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: labelColor(label.color) }}
/>
{label.name}
</span>
))}
{hiddenLabels > 0 ? (
<span className="text-xs text-muted">+{hiddenLabels} labels</span>
) : null}
</div>
</div>
);
},
},
{
accessorKey: "boards",
header: "Boards",
cell: ({ row }) => {
const boards = linkedBoardsByRepository[row.original.id] ?? [];
const visibleBoards = boards.slice(0, 3);
const hiddenBoards = Math.max(boards.length - visibleBoards.length, 0);
if (boards.length === 0) {
return <span className="text-sm text-muted">No linked boards</span>;
}
return (
<div className="flex max-w-[240px] flex-wrap gap-1.5">
{visibleBoards.map((board) => (
<Badge key={board.id} variant="outline" className="normal-case">
{board.name}
</Badge>
))}
{hiddenBoards > 0 ? (
<Badge variant="outline">+{hiddenBoards}</Badge>
) : null}
</div>
);
},
},