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) <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang
2026-04-10 18:24:48 +08:00
parent b52c048c8e
commit bd9d88d50c
5 changed files with 87 additions and 0 deletions

View File

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

View File

@@ -75,6 +75,25 @@ function ProjectRow({ project }: { project: Project }) {
{statusCfg.label}
</span>
{/* Progress */}
<span className="flex w-24 items-center justify-center gap-1.5 shrink-0">
{project.issue_count > 0 ? (
<>
<span className="relative h-1.5 w-12 rounded-full bg-muted overflow-hidden">
<span
className="absolute inset-y-0 left-0 rounded-full bg-emerald-500 transition-all"
style={{ width: `${Math.round((project.done_count / project.issue_count) * 100)}%` }}
/>
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{project.done_count}/{project.issue_count}
</span>
</>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
</span>
{/* Lead */}
<span className="flex w-10 items-center justify-center shrink-0">
{project.lead_type && project.lead_id ? (
@@ -443,6 +462,7 @@ export function ProjectsPage() {
<span className="min-w-0 flex-1">Name</span>
<span className="w-24 text-center shrink-0">Priority</span>
<span className="w-28 text-center shrink-0">Status</span>
<span className="w-24 text-center shrink-0">Progress</span>
<span className="w-10 text-center shrink-0">Lead</span>
<span className="w-20 text-right shrink-0">Created</span>
</div>

View File

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

View File

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

View File

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