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 {