diff --git a/apps/web/features/issues/components/batch-action-toolbar.tsx b/apps/web/features/issues/components/batch-action-toolbar.tsx index 9b259e3e..7ac9905f 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 aa070940..56a33580 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 1e47b7d7..05add4f7 100644 --- a/apps/web/features/issues/store.ts +++ b/apps/web/features/issues/store.ts @@ -8,11 +8,16 @@ import { createLogger } from "@/shared/logger"; const logger = createLogger("issue-store"); +const CLOSED_PAGE_SIZE = 50; + interface IssueState { issues: Issue[]; loading: boolean; activeIssueId: string | null; + hasMoreClosed: boolean; + closedOffset: number; fetch: () => Promise; + fetchMoreClosed: () => Promise; setIssues: (issues: Issue[]) => void; addIssue: (issue: Issue) => void; updateIssue: (id: string, updates: Partial) => void; @@ -24,15 +29,28 @@ export const useIssueStore = create((set, get) => ({ issues: [], loading: true, activeIssueId: null, + hasMoreClosed: false, + closedOffset: 0, fetch: async () => { logger.debug("fetch start"); 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 }); + // Phase 1: fetch ALL open issues (no limit) + // Phase 2: fetch first page of closed issues + const [openRes, closedRes] = await Promise.all([ + api.listIssues({ open_only: true }), + api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }), + ]); + const allIssues = [...openRes.issues, ...closedRes.issues]; + logger.info("fetched", openRes.issues.length, "open +", closedRes.issues.length, "closed issues"); + set({ + issues: allIssues, + loading: false, + hasMoreClosed: closedRes.issues.length >= CLOSED_PAGE_SIZE, + closedOffset: CLOSED_PAGE_SIZE, + }); } catch (err) { logger.error("fetch failed", err); toast.error("Failed to load issues"); @@ -40,6 +58,28 @@ export const useIssueStore = create((set, get) => ({ } }, + fetchMoreClosed: async () => { + const { closedOffset } = get(); + try { + const res = await api.listIssues({ + status: "done", + limit: CLOSED_PAGE_SIZE, + offset: closedOffset, + }); + set((s) => ({ + issues: [ + ...s.issues, + ...res.issues.filter((ni) => !s.issues.some((ei) => ei.id === ni.id)), + ], + closedOffset: closedOffset + CLOSED_PAGE_SIZE, + hasMoreClosed: res.issues.length >= CLOSED_PAGE_SIZE, + })); + } catch (err) { + logger.error("fetchMoreClosed failed", err); + toast.error("Failed to load more issues"); + } + }, + setIssues: (issues) => set({ issues }), addIssue: (issue) => set((s) => ({ diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 5d7d2e79..2c3a4207 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -172,6 +172,7 @@ export class ApiClient { if (params?.status) search.set("status", params.status); if (params?.priority) search.set("priority", params.priority); if (params?.assignee_id) search.set("assignee_id", params.assignee_id); + if (params?.open_only) search.set("open_only", "true"); return this.fetch(`/api/issues?${search}`); } diff --git a/apps/web/shared/types/api.ts b/apps/web/shared/types/api.ts index 882750bc..39e4d712 100644 --- a/apps/web/shared/types/api.ts +++ b/apps/web/shared/types/api.ts @@ -32,6 +32,7 @@ export interface ListIssuesParams { status?: IssueStatus; priority?: IssuePriority; assignee_id?: string; + open_only?: boolean; } export interface ListIssuesResponse { diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 0259bb21..389bbede 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -83,6 +83,42 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { ctx := r.Context() workspaceID := resolveWorkspaceID(r) + wsUUID := parseUUID(workspaceID) + + // Parse optional filter params + var priorityFilter pgtype.Text + if p := r.URL.Query().Get("priority"); p != "" { + priorityFilter = pgtype.Text{String: p, Valid: true} + } + var assigneeFilter pgtype.UUID + if a := r.URL.Query().Get("assignee_id"); a != "" { + assigneeFilter = parseUUID(a) + } + + // open_only=true returns all non-done/cancelled issues (no limit). + if r.URL.Query().Get("open_only") == "true" { + issues, err := h.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{ + WorkspaceID: wsUUID, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list issues") + return + } + + prefix := h.getIssuePrefix(ctx, wsUUID) + resp := make([]IssueResponse, len(issues)) + for i, issue := range issues { + resp[i] = issueToResponse(issue, prefix) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "issues": resp, + "total": len(resp), + }) + return + } limit := 100 offset := 0 @@ -97,22 +133,13 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { } } - // Parse optional filter params var statusFilter pgtype.Text if s := r.URL.Query().Get("status"); s != "" { statusFilter = pgtype.Text{String: s, Valid: true} } - var priorityFilter pgtype.Text - if p := r.URL.Query().Get("priority"); p != "" { - priorityFilter = pgtype.Text{String: p, Valid: true} - } - var assigneeFilter pgtype.UUID - if a := r.URL.Query().Get("assignee_id"); a != "" { - assigneeFilter = parseUUID(a) - } issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, Limit: int32(limit), Offset: int32(offset), Status: statusFilter, @@ -124,7 +151,18 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { return } - prefix := h.getIssuePrefix(ctx, parseUUID(workspaceID)) + // Get the true total count for pagination awareness. + total, err := h.Queries.CountIssues(ctx, db.CountIssuesParams{ + WorkspaceID: wsUUID, + Status: statusFilter, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + }) + if err != nil { + total = int64(len(issues)) + } + + prefix := h.getIssuePrefix(ctx, wsUUID) resp := make([]IssueResponse, len(issues)) for i, issue := range issues { resp[i] = issueToResponse(issue, prefix) @@ -132,7 +170,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 f899eb6e..97ec6788 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, @@ -254,6 +281,60 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue return items, nil } +const listOpenIssues = `-- name: ListOpenIssues :many +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue +WHERE workspace_id = $1 + AND status NOT IN ('done', 'cancelled') + AND ($2::text IS NULL OR priority = $2) + AND ($3::uuid IS NULL OR assignee_id = $3) +ORDER BY position ASC, created_at DESC +` + +type ListOpenIssuesParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` +} + +func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) ([]Issue, error) { + rows, err := q.db.Query(ctx, listOpenIssues, arg.WorkspaceID, arg.Priority, arg.AssigneeID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Issue{} + for rows.Next() { + var i Issue + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Title, + &i.Description, + &i.Status, + &i.Priority, + &i.AssigneeType, + &i.AssigneeID, + &i.CreatorType, + &i.CreatorID, + &i.ParentIssueID, + &i.AcceptanceCriteria, + &i.ContextRefs, + &i.Position, + &i.DueDate, + &i.CreatedAt, + &i.UpdatedAt, + &i.Number, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateIssue = `-- name: UpdateIssue :one UPDATE issue SET title = COALESCE($2, title), diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index edc229c3..c8821ffb 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -51,3 +51,18 @@ RETURNING *; -- name: DeleteIssue :exec DELETE FROM issue WHERE id = $1; + +-- name: ListOpenIssues :many +SELECT * FROM issue +WHERE workspace_id = $1 + AND status NOT IN ('done', 'cancelled') + 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')) +ORDER BY position ASC, created_at DESC; + +-- 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'));