diff --git a/apps/web/core/issues/index.ts b/apps/web/core/issues/index.ts
index a746aeb2..4b194c98 100644
--- a/apps/web/core/issues/index.ts
+++ b/apps/web/core/issues/index.ts
@@ -8,6 +8,7 @@ export {
} from "./queries";
export {
+ useLoadMoreDoneIssues,
useCreateIssue,
useUpdateIssue,
useDeleteIssue,
diff --git a/apps/web/core/issues/mutations.ts b/apps/web/core/issues/mutations.ts
index 47a2953e..42575267 100644
--- a/apps/web/core/issues/mutations.ts
+++ b/apps/web/core/issues/mutations.ts
@@ -1,6 +1,7 @@
+import { useState, useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/shared/api";
-import { issueKeys } from "./queries";
+import { issueKeys, CLOSED_PAGE_SIZE } from "./queries";
import { useWorkspaceId } from "@core/hooks";
import type { Issue, IssueReaction } from "@/shared/types";
import type {
@@ -10,6 +11,49 @@ import type {
} from "@/shared/types";
import type { TimelineEntry, IssueSubscriber, Reaction } from "@/shared/types";
+// ---------------------------------------------------------------------------
+// Done issue pagination
+// ---------------------------------------------------------------------------
+
+export function useLoadMoreDoneIssues() {
+ const qc = useQueryClient();
+ const wsId = useWorkspaceId();
+ const [isLoading, setIsLoading] = useState(false);
+
+ const cache = qc.getQueryData(issueKeys.list(wsId));
+ const doneLoaded = cache
+ ? cache.issues.filter((i) => i.status === "done").length
+ : 0;
+ const doneTotal = cache?.doneTotal ?? 0;
+ const hasMore = doneLoaded < doneTotal;
+
+ const loadMore = useCallback(async () => {
+ if (isLoading || !hasMore) return;
+ setIsLoading(true);
+ try {
+ const res = await api.listIssues({
+ status: "done",
+ limit: CLOSED_PAGE_SIZE,
+ offset: doneLoaded,
+ });
+ qc.setQueryData(issueKeys.list(wsId), (old) => {
+ if (!old) return old;
+ const existingIds = new Set(old.issues.map((i) => i.id));
+ const newIssues = res.issues.filter((i) => !existingIds.has(i.id));
+ return {
+ ...old,
+ issues: [...old.issues, ...newIssues],
+ doneTotal: res.total,
+ };
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ }, [qc, wsId, doneLoaded, hasMore, isLoading]);
+
+ return { loadMore, hasMore, isLoading };
+}
+
// ---------------------------------------------------------------------------
// Issue CRUD
// ---------------------------------------------------------------------------
@@ -22,7 +66,12 @@ export function useCreateIssue() {
onSuccess: (newIssue) => {
qc.setQueryData(issueKeys.list(wsId), (old) =>
old && !old.issues.some((i) => i.id === newIssue.id)
- ? { ...old, issues: [...old.issues, newIssue], total: old.total + 1 }
+ ? {
+ ...old,
+ issues: [...old.issues, newIssue],
+ total: old.total + 1,
+ doneTotal: (old.doneTotal ?? 0) + (newIssue.status === "done" ? 1 : 0),
+ }
: old,
);
},
@@ -82,15 +131,16 @@ export function useDeleteIssue() {
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData(issueKeys.list(wsId));
- qc.setQueryData(issueKeys.list(wsId), (old) =>
- old
- ? {
- ...old,
- issues: old.issues.filter((i) => i.id !== id),
- total: old.total - 1,
- }
- : old,
- );
+ qc.setQueryData(issueKeys.list(wsId), (old) => {
+ if (!old) return old;
+ const deleted = old.issues.find((i) => i.id === id);
+ return {
+ ...old,
+ issues: old.issues.filter((i) => i.id !== id),
+ total: old.total - 1,
+ doneTotal: (old.doneTotal ?? 0) - (deleted?.status === "done" ? 1 : 0),
+ };
+ });
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
return { prevList };
},
@@ -146,15 +196,19 @@ export function useBatchDeleteIssues() {
onMutate: async (ids) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData(issueKeys.list(wsId));
- qc.setQueryData(issueKeys.list(wsId), (old) =>
- old
- ? {
- ...old,
- issues: old.issues.filter((i) => !ids.includes(i.id)),
- total: old.total - ids.length,
- }
- : old,
- );
+ qc.setQueryData(issueKeys.list(wsId), (old) => {
+ if (!old) return old;
+ const idSet = new Set(ids);
+ const doneDeleted = old.issues.filter(
+ (i) => idSet.has(i.id) && i.status === "done",
+ ).length;
+ return {
+ ...old,
+ issues: old.issues.filter((i) => !idSet.has(i.id)),
+ total: old.total - ids.length,
+ doneTotal: (old.doneTotal ?? 0) - doneDeleted,
+ };
+ });
return { prevList };
},
onError: (_err, _ids, ctx) => {
diff --git a/apps/web/core/issues/queries.ts b/apps/web/core/issues/queries.ts
index 2d30d5a3..ec358933 100644
--- a/apps/web/core/issues/queries.ts
+++ b/apps/web/core/issues/queries.ts
@@ -12,14 +12,15 @@ export const issueKeys = {
["issues", "subscribers", issueId] as const,
};
-const CLOSED_PAGE_SIZE = 50;
+export const CLOSED_PAGE_SIZE = 50;
/**
- * CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total }),
+ * CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total, doneTotal }),
* but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters
* must use setQueryData(...) — NOT setQueryData.
*
- * Fetches all open issues + first page of closed issues (matching main's pagination strategy).
+ * Fetches all open issues + first page of done issues. Use useLoadMoreDoneIssues()
+ * to paginate additional done items into the cache.
*/
export function issueListOptions(wsId: string) {
return queryOptions({
@@ -32,6 +33,7 @@ export function issueListOptions(wsId: string) {
return {
issues: [...openRes.issues, ...closedRes.issues],
total: openRes.total + closedRes.total,
+ doneTotal: closedRes.total,
};
},
select: (data) => data.issues,
diff --git a/apps/web/core/issues/ws-updaters.ts b/apps/web/core/issues/ws-updaters.ts
index 7486c4fe..2e9d0450 100644
--- a/apps/web/core/issues/ws-updaters.ts
+++ b/apps/web/core/issues/ws-updaters.ts
@@ -8,11 +8,15 @@ export function onIssueCreated(
wsId: string,
issue: Issue,
) {
- qc.setQueryData(issueKeys.list(wsId), (old) =>
- old && !old.issues.some((i) => i.id === issue.id)
- ? { ...old, issues: [...old.issues, issue], total: old.total + 1 }
- : old,
- );
+ qc.setQueryData(issueKeys.list(wsId), (old) => {
+ if (!old || old.issues.some((i) => i.id === issue.id)) return old;
+ return {
+ ...old,
+ issues: [...old.issues, issue],
+ total: old.total + 1,
+ doneTotal: (old.doneTotal ?? 0) + (issue.status === "done" ? 1 : 0),
+ };
+ });
}
export function onIssueUpdated(
@@ -20,16 +24,25 @@ export function onIssueUpdated(
wsId: string,
issue: Partial & { id: string },
) {
- qc.setQueryData(issueKeys.list(wsId), (old) =>
- old
- ? {
- ...old,
- issues: old.issues.map((i) =>
- i.id === issue.id ? { ...i, ...issue } : i,
- ),
- }
- : old,
- );
+ qc.setQueryData(issueKeys.list(wsId), (old) => {
+ if (!old) return old;
+ const prev = old.issues.find((i) => i.id === issue.id);
+ const wasDone = prev?.status === "done";
+ const isDone = issue.status === "done";
+ // Only adjust doneTotal when status field is present and actually changed
+ let doneDelta = 0;
+ if (issue.status !== undefined) {
+ if (!wasDone && isDone) doneDelta = 1;
+ else if (wasDone && !isDone) doneDelta = -1;
+ }
+ return {
+ ...old,
+ issues: old.issues.map((i) =>
+ i.id === issue.id ? { ...i, ...issue } : i,
+ ),
+ doneTotal: (old.doneTotal ?? 0) + doneDelta,
+ };
+ });
qc.setQueryData(issueKeys.detail(wsId, issue.id), (old) =>
old ? { ...old, ...issue } : old,
);
@@ -40,15 +53,16 @@ export function onIssueDeleted(
wsId: string,
issueId: string,
) {
- qc.setQueryData(issueKeys.list(wsId), (old) =>
- old
- ? {
- ...old,
- issues: old.issues.filter((i) => i.id !== issueId),
- total: old.total - 1,
- }
- : old,
- );
+ qc.setQueryData(issueKeys.list(wsId), (old) => {
+ if (!old) return old;
+ const deleted = old.issues.find((i) => i.id === issueId);
+ return {
+ ...old,
+ issues: old.issues.filter((i) => i.id !== issueId),
+ total: old.total - 1,
+ doneTotal: (old.doneTotal ?? 0) - (deleted?.status === "done" ? 1 : 0),
+ };
+ });
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
diff --git a/apps/web/features/issues/components/board-column.tsx b/apps/web/features/issues/components/board-column.tsx
index 11bae0a5..88e177e9 100644
--- a/apps/web/features/issues/components/board-column.tsx
+++ b/apps/web/features/issues/components/board-column.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useMemo } from "react";
+import { useMemo, type ReactNode } from "react";
import { EyeOff, MoreHorizontal, Plus } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useDroppable } from "@dnd-kit/core";
@@ -23,10 +23,12 @@ export function BoardColumn({
status,
issueIds,
issueMap,
+ footer,
}: {
status: IssueStatus;
issueIds: string[];
issueMap: Map;
+ footer?: ReactNode;
}) {
const cfg = STATUS_CONFIG[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
@@ -106,6 +108,7 @@ export function BoardColumn({
No issues
)}
+ {footer}
);
diff --git a/apps/web/features/issues/components/board-view.tsx b/apps/web/features/issues/components/board-view.tsx
index c6d42f1d..026897ba 100644
--- a/apps/web/features/issues/components/board-view.tsx
+++ b/apps/web/features/issues/components/board-view.tsx
@@ -15,9 +15,10 @@ import {
type DragOverEvent,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
-import { Eye, MoreHorizontal } from "lucide-react";
+import { Eye, Loader2, MoreHorizontal } from "lucide-react";
import type { Issue, IssueStatus } from "@/shared/types";
import { Button } from "@/components/ui/button";
+import { useLoadMoreDoneIssues } from "@core/issues/mutations";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -110,6 +111,7 @@ export function BoardView({
}) {
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
+ const { loadMore, hasMore, isLoading: loadingMore } = useLoadMoreDoneIssues();
// --- Drag state ---
const [activeIssue, setActiveIssue] = useState(null);
@@ -272,6 +274,21 @@ export function BoardView({
status={status}
issueIds={columns[status] ?? []}
issueMap={issueMapRef.current}
+ footer={
+ status === "done" && hasMore ? (
+
+ ) : undefined
+ }
/>
))}
diff --git a/apps/web/shared/types/api.ts b/apps/web/shared/types/api.ts
index 39e4d712..c199221f 100644
--- a/apps/web/shared/types/api.ts
+++ b/apps/web/shared/types/api.ts
@@ -38,6 +38,8 @@ export interface ListIssuesParams {
export interface ListIssuesResponse {
issues: Issue[];
total: number;
+ /** True total of done issues in the workspace (for load-more pagination). Not returned by backend API — set by the frontend query function. */
+ doneTotal?: number;
}
export interface UpdateMeRequest {