mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user