From bd9d88d50ccdfc168a15ac649e5fd3ac1968b3cf Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 10 Apr 2026 18:24:48 +0800 Subject: [PATCH] feat(projects): show completion progress (done/total issues) in project list Add a progress column to the projects list page that displays a mini progress bar and done/total issue count for each project. Backend batch-fetches issue stats per project using a single query for efficiency. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/types/project.ts | 2 ++ .../projects/components/projects-page.tsx | 20 +++++++++++ server/internal/handler/project.go | 22 ++++++++++++ server/pkg/db/generated/project.sql.go | 35 +++++++++++++++++++ server/pkg/db/queries/project.sql | 8 +++++ 5 files changed, 87 insertions(+) diff --git a/packages/core/types/project.ts b/packages/core/types/project.ts index d81d72440..868be238c 100644 --- a/packages/core/types/project.ts +++ b/packages/core/types/project.ts @@ -14,6 +14,8 @@ export interface Project { lead_id: string | null; created_at: string; updated_at: string; + issue_count: number; + done_count: number; } export interface CreateProjectRequest { diff --git a/packages/views/projects/components/projects-page.tsx b/packages/views/projects/components/projects-page.tsx index 20531ced5..7c76d057d 100644 --- a/packages/views/projects/components/projects-page.tsx +++ b/packages/views/projects/components/projects-page.tsx @@ -75,6 +75,25 @@ function ProjectRow({ project }: { project: Project }) { {statusCfg.label} + {/* Progress */} + + {project.issue_count > 0 ? ( + <> + + + + + {project.done_count}/{project.issue_count} + + + ) : ( + -- + )} + + {/* Lead */} {project.lead_type && project.lead_id ? ( @@ -443,6 +462,7 @@ export function ProjectsPage() { Name Priority Status + Progress Lead Created diff --git a/server/internal/handler/project.go b/server/internal/handler/project.go index c87db0a1e..92b96dca6 100644 --- a/server/internal/handler/project.go +++ b/server/internal/handler/project.go @@ -23,6 +23,8 @@ type ProjectResponse struct { LeadID *string `json:"lead_id"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` + IssueCount int64 `json:"issue_count"` + DoneCount int64 `json:"done_count"` } func projectToResponse(p db.Project) ProjectResponse { @@ -80,9 +82,29 @@ func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to list projects") return } + + // Batch-fetch issue stats for all projects + statsMap := make(map[string]db.GetProjectIssueStatsRow) + if len(projects) > 0 { + projectIDs := make([]pgtype.UUID, len(projects)) + for i, p := range projects { + projectIDs[i] = p.ID + } + stats, err := h.Queries.GetProjectIssueStats(r.Context(), projectIDs) + if err == nil { + for _, s := range stats { + statsMap[uuidToString(s.ProjectID)] = s + } + } + } + resp := make([]ProjectResponse, len(projects)) for i, p := range projects { resp[i] = projectToResponse(p) + if s, ok := statsMap[resp[i].ID]; ok { + resp[i].IssueCount = s.TotalCount + resp[i].DoneCount = s.DoneCount + } } writeJSON(w, http.StatusOK, map[string]any{"projects": resp, "total": len(resp)}) } diff --git a/server/pkg/db/generated/project.sql.go b/server/pkg/db/generated/project.sql.go index 3cd46272d..2147e1479 100644 --- a/server/pkg/db/generated/project.sql.go +++ b/server/pkg/db/generated/project.sql.go @@ -133,6 +133,41 @@ func (q *Queries) GetProjectInWorkspace(ctx context.Context, arg GetProjectInWor return i, err } +const getProjectIssueStats = `-- name: GetProjectIssueStats :many +SELECT project_id, + count(*)::bigint AS total_count, + count(*) FILTER (WHERE status IN ('done', 'cancelled'))::bigint AS done_count +FROM issue +WHERE project_id = ANY($1::uuid[]) +GROUP BY project_id +` + +type GetProjectIssueStatsRow struct { + ProjectID pgtype.UUID `json:"project_id"` + TotalCount int64 `json:"total_count"` + DoneCount int64 `json:"done_count"` +} + +func (q *Queries) GetProjectIssueStats(ctx context.Context, projectIds []pgtype.UUID) ([]GetProjectIssueStatsRow, error) { + rows, err := q.db.Query(ctx, getProjectIssueStats, projectIds) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetProjectIssueStatsRow{} + for rows.Next() { + var i GetProjectIssueStatsRow + if err := rows.Scan(&i.ProjectID, &i.TotalCount, &i.DoneCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listProjects = `-- name: ListProjects :many SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority FROM project WHERE workspace_id = $1 diff --git a/server/pkg/db/queries/project.sql b/server/pkg/db/queries/project.sql index 7d882fcb3..c126d56ae 100644 --- a/server/pkg/db/queries/project.sql +++ b/server/pkg/db/queries/project.sql @@ -40,3 +40,11 @@ DELETE FROM project WHERE id = $1; -- name: CountIssuesByProject :one SELECT count(*) FROM issue WHERE project_id = $1; + +-- name: GetProjectIssueStats :many +SELECT project_id, + count(*)::bigint AS total_count, + count(*) FILTER (WHERE status IN ('done', 'cancelled'))::bigint AS done_count +FROM issue +WHERE project_id = ANY(sqlc.arg('project_ids')::uuid[]) +GROUP BY project_id;