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) <noreply@anthropic.com>
This commit is contained in:
yushen
2026-04-08 13:31:56 +08:00
parent 52a9a6ae5f
commit 101cf87f10
7 changed files with 68 additions and 18 deletions

View File

@@ -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);

View File

@@ -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);
});
},
[]

View File

@@ -30,9 +30,20 @@ export const useIssueStore = create<IssueState>((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");

View File

@@ -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);
});
},
[],

View File

@@ -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,
})
}

View File

@@ -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,

View File

@@ -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;