From 101cf87f10bd9a0e30b04bb42db2c267ba479546 Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 8 Apr 2026 13:31:56 +0800 Subject: [PATCH] fix(issues): load all issues by paginating through API results The frontend was fetching at most 200 issues in a single request, causing workspaces with more than 200 total issues to show incomplete data in status columns (especially "done"). The backend also returned len(results) as "total" instead of the actual database count. - Add CountIssues SQL query to return true total from the database - Update ListIssues handler to return the real total count - Implement auto-pagination in the issue store's fetch() to load all pages - Consolidate error-recovery fetches to use the store's paginated fetch Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/batch-action-toolbar.tsx | 8 ++---- .../issues/components/issues-page.tsx | 4 +-- apps/web/features/issues/store.ts | 17 +++++++++--- .../my-issues/components/my-issues-page.tsx | 4 +-- server/internal/handler/issue.go | 19 ++++++++++--- server/pkg/db/generated/issue.sql.go | 27 +++++++++++++++++++ server/pkg/db/queries/issue.sql | 7 +++++ 7 files changed, 68 insertions(+), 18 deletions(-) diff --git a/apps/web/features/issues/components/batch-action-toolbar.tsx b/apps/web/features/issues/components/batch-action-toolbar.tsx index 9b259e3e8c..7ac9905f10 100644 --- a/apps/web/features/issues/components/batch-action-toolbar.tsx +++ b/apps/web/features/issues/components/batch-action-toolbar.tsx @@ -53,9 +53,7 @@ export function BatchActionToolbar() { toast.success(`Updated ${count} issue${count > 1 ? "s" : ""}`); } catch { toast.error("Failed to update issues"); - api.listIssues({ limit: 200 }).then((res) => { - useIssueStore.getState().setIssues(res.issues); - }).catch(console.error); + useIssueStore.getState().fetch().catch(console.error); } finally { setLoading(false); } @@ -72,9 +70,7 @@ export function BatchActionToolbar() { toast.success(`Deleted ${count} issue${count > 1 ? "s" : ""}`); } catch { toast.error("Failed to delete issues"); - api.listIssues({ limit: 200 }).then((res) => { - useIssueStore.getState().setIssues(res.issues); - }).catch(console.error); + useIssueStore.getState().fetch().catch(console.error); } finally { setLoading(false); setDeleteOpen(false); diff --git a/apps/web/features/issues/components/issues-page.tsx b/apps/web/features/issues/components/issues-page.tsx index aa0709405b..56a3358097 100644 --- a/apps/web/features/issues/components/issues-page.tsx +++ b/apps/web/features/issues/components/issues-page.tsx @@ -82,9 +82,7 @@ export function IssuesPage() { api.updateIssue(issueId, updates).catch(() => { toast.error("Failed to move issue"); - api.listIssues({ limit: 200 }).then((res) => { - useIssueStore.getState().setIssues(res.issues); - }).catch(console.error); + useIssueStore.getState().fetch().catch(console.error); }); }, [] diff --git a/apps/web/features/issues/store.ts b/apps/web/features/issues/store.ts index 1e47b7d73e..1048035581 100644 --- a/apps/web/features/issues/store.ts +++ b/apps/web/features/issues/store.ts @@ -30,9 +30,20 @@ export const useIssueStore = create((set, get) => ({ const isInitialLoad = get().issues.length === 0; if (isInitialLoad) set({ loading: true }); try { - const res = await api.listIssues({ limit: 200 }); - logger.info("fetched", res.issues.length, "issues"); - set({ issues: res.issues, loading: false }); + const pageSize = 200; + let offset = 0; + let allIssues: Issue[] = []; + let total = 0; + // Fetch all pages + for (;;) { + const res = await api.listIssues({ limit: pageSize, offset }); + allIssues = allIssues.concat(res.issues); + total = res.total; + if (allIssues.length >= total || res.issues.length < pageSize) break; + offset += pageSize; + } + logger.info("fetched", allIssues.length, "of", total, "issues"); + set({ issues: allIssues, loading: false }); } catch (err) { logger.error("fetch failed", err); toast.error("Failed to load issues"); diff --git a/apps/web/features/my-issues/components/my-issues-page.tsx b/apps/web/features/my-issues/components/my-issues-page.tsx index ac7b13ba7b..ba40393f5d 100644 --- a/apps/web/features/my-issues/components/my-issues-page.tsx +++ b/apps/web/features/my-issues/components/my-issues-page.tsx @@ -122,9 +122,7 @@ export function MyIssuesPage() { api.updateIssue(issueId, updates).catch(() => { toast.error("Failed to move issue"); - api.listIssues({ limit: 200 }).then((res) => { - useIssueStore.getState().setIssues(res.issues); - }).catch(console.error); + useIssueStore.getState().fetch().catch(console.error); }); }, [], diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 418252e24c..620f2ac0eb 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -111,8 +111,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { assigneeFilter = parseUUID(a) } + wsUUID := parseUUID(workspaceID) + issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, Limit: int32(limit), Offset: int32(offset), Status: statusFilter, @@ -124,7 +126,18 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { return } - prefix := h.getIssuePrefix(ctx, parseUUID(workspaceID)) + total, err := h.Queries.CountIssues(ctx, db.CountIssuesParams{ + WorkspaceID: wsUUID, + Status: statusFilter, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to count issues") + return + } + + prefix := h.getIssuePrefix(ctx, wsUUID) resp := make([]IssueResponse, len(issues)) for i, issue := range issues { resp[i] = issueToResponse(issue, prefix) @@ -132,7 +145,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "issues": resp, - "total": len(resp), + "total": total, }) } diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index f899eb6ebe..95b15a244b 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -11,6 +11,33 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countIssues = `-- name: CountIssues :one +SELECT count(*) FROM issue +WHERE workspace_id = $1 + AND ($2::text IS NULL OR status = $2) + AND ($3::text IS NULL OR priority = $3) + AND ($4::uuid IS NULL OR assignee_id = $4) +` + +type CountIssuesParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + Status pgtype.Text `json:"status"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` +} + +func (q *Queries) CountIssues(ctx context.Context, arg CountIssuesParams) (int64, error) { + row := q.db.QueryRow(ctx, countIssues, + arg.WorkspaceID, + arg.Status, + arg.Priority, + arg.AssigneeID, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + const createIssue = `-- name: CreateIssue :one INSERT INTO issue ( workspace_id, title, description, status, priority, diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index edc229c31c..ce89cce73f 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -7,6 +7,13 @@ WHERE workspace_id = $1 ORDER BY position ASC, created_at DESC LIMIT $2 OFFSET $3; +-- name: CountIssues :one +SELECT count(*) FROM issue +WHERE workspace_id = $1 + AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) + AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) + AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')); + -- name: GetIssue :one SELECT * FROM issue WHERE id = $1;