From 4d028e473ff1f8f64ebbe2d033eb6dae38c227ff Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Sun, 5 Jul 2026 21:31:23 +0400 Subject: [PATCH 1/2] feat(cycle): compute real cycle and module completion progress Cycle and module list progress was faked from status (0% or 100%). Adds bulk per-project state-group distribution queries and cycles-progress / modules-progress endpoints (mirroring epics-progress), and renders both list pages' progress from completed/total. The modules progress sort now uses the real value too. Closes #185 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/internal/handler/cycle.go | 31 +++++++++++++++++++++ apps/api/internal/handler/cycle_test.go | 23 ++++++++++++++++ apps/api/internal/handler/module.go | 31 +++++++++++++++++++++ apps/api/internal/router/router.go | 2 ++ apps/api/internal/service/cycle.go | 20 ++++++++++++++ apps/api/internal/service/module.go | 21 +++++++++++++++ apps/api/internal/store/cycle.go | 36 +++++++++++++++++++++++++ apps/api/internal/store/module.go | 36 +++++++++++++++++++++++++ apps/web/src/pages/CyclesPage.tsx | 29 +++++++++++++++++--- apps/web/src/pages/ModulesPage.tsx | 35 +++++++++++++++++------- apps/web/src/services/cycleService.ts | 21 +++++++++++++++ apps/web/src/services/moduleService.ts | 21 +++++++++++++++ 12 files changed, 293 insertions(+), 13 deletions(-) diff --git a/apps/api/internal/handler/cycle.go b/apps/api/internal/handler/cycle.go index ac74ea99..ed859d05 100644 --- a/apps/api/internal/handler/cycle.go +++ b/apps/api/internal/handler/cycle.go @@ -60,6 +60,37 @@ func (h *CycleHandler) List(c *gin.Context) { c.JSON(http.StatusOK, list) } +// CyclesProgress returns, per cycle in the project, issue counts grouped by +// state group (plus a total), keyed by cycle id. +// GET /api/workspaces/:slug/projects/:projectId/cycles-progress/ +func (h *CycleHandler) CyclesProgress(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + slug := c.Param("slug") + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + return + } + prog, err := h.Cycle.ProgressBulk(c.Request.Context(), slug, projectID, user.ID) + if err != nil { + if err == service.ErrProjectForbidden || err == service.ErrProjectNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load cycle progress"}) + return + } + if prog == nil { + c.JSON(http.StatusOK, gin.H{}) + return + } + c.JSON(http.StatusOK, prog) +} + // Create creates a cycle. // POST /api/workspaces/:slug/projects/:projectId/cycles/ func (h *CycleHandler) Create(c *gin.Context) { diff --git a/apps/api/internal/handler/cycle_test.go b/apps/api/internal/handler/cycle_test.go index 3a946db7..2ad311b5 100644 --- a/apps/api/internal/handler/cycle_test.go +++ b/apps/api/internal/handler/cycle_test.go @@ -52,6 +52,29 @@ func TestCycle_CRUD(t *testing.T) { require.Equal(t, http.StatusNoContent, rr5.Code) } +func TestCycle_ProgressBulk(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + cycle := testutil.CreateCycle(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + issuesBase := cycleBase(w.Workspace.Slug, w.Project.ID.String()) + cycle.ID.String() + "/issues/" + for range []int{0, 1} { + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + add := ts.POST(issuesBase, map[string]any{"issue_id": issue.ID.String()}, w.Session) + require.Less(t, add.Code, 300, "body=%s", add.Body.String()) + } + + rr := ts.GET( + "/api/workspaces/"+w.Workspace.Slug+"/projects/"+w.Project.ID.String()+"/cycles-progress/", + w.Session, + ) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + m := testutil.MustJSONMap(t, rr) + entry, ok := m[cycle.ID.String()].(map[string]any) + require.True(t, ok, "expected progress for the cycle, body=%s", rr.Body.String()) + assert.Equal(t, float64(2), entry["total"]) + assert.Equal(t, float64(0), entry["completed"]) +} + func TestCycle_Issues_AddListRemove(t *testing.T) { ts := testutil.NewTestServer(t) w := testutil.SeedWorld(t, ts.DB) diff --git a/apps/api/internal/handler/module.go b/apps/api/internal/handler/module.go index af84c2bd..754ca895 100644 --- a/apps/api/internal/handler/module.go +++ b/apps/api/internal/handler/module.go @@ -87,6 +87,37 @@ func (h *ModuleHandler) List(c *gin.Context) { c.JSON(http.StatusOK, list) } +// ModulesProgress returns, per module in the project, issue counts grouped by +// state group (plus a total), keyed by module id. +// GET /api/workspaces/:slug/projects/:projectId/modules-progress/ +func (h *ModuleHandler) ModulesProgress(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + slug := c.Param("slug") + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + return + } + prog, err := h.Module.ProgressBulk(c.Request.Context(), slug, projectID, user.ID) + if err != nil { + if err == service.ErrProjectForbidden || err == service.ErrProjectNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load module progress"}) + return + } + if prog == nil { + c.JSON(http.StatusOK, gin.H{}) + return + } + c.JSON(http.StatusOK, prog) +} + // Create creates a module. // POST /api/workspaces/:slug/projects/:projectId/modules/ func (h *ModuleHandler) Create(c *gin.Context) { diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index da86ee1d..bae3404d 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -361,6 +361,7 @@ func New(cfg Config) *gin.Engine { api.POST("/workspaces/:slug/projects/:projectId/issues-bulk/reorder/", issueHandler.BulkReorder) api.GET("/workspaces/:slug/projects/:projectId/cycles/", cycleHandler.List) + api.GET("/workspaces/:slug/projects/:projectId/cycles-progress/", cycleHandler.CyclesProgress) api.POST("/workspaces/:slug/projects/:projectId/cycles/", cycleHandler.Create) api.GET("/workspaces/:slug/projects/:projectId/cycles/:cycleId/", cycleHandler.Get) api.PATCH("/workspaces/:slug/projects/:projectId/cycles/:cycleId/", cycleHandler.Update) @@ -373,6 +374,7 @@ func New(cfg Config) *gin.Engine { api.GET("/workspaces/:slug/projects/:projectId/cycles/:cycleId/analytics", cycleHandler.Analytics) api.GET("/workspaces/:slug/projects/:projectId/modules/", moduleHandler.List) + api.GET("/workspaces/:slug/projects/:projectId/modules-progress/", moduleHandler.ModulesProgress) api.POST("/workspaces/:slug/projects/:projectId/modules/", moduleHandler.Create) api.GET("/workspaces/:slug/projects/:projectId/modules/:moduleId/", moduleHandler.Get) api.PATCH("/workspaces/:slug/projects/:projectId/modules/:moduleId/", moduleHandler.Update) diff --git a/apps/api/internal/service/cycle.go b/apps/api/internal/service/cycle.go index 05bf9df2..666382a4 100644 --- a/apps/api/internal/service/cycle.go +++ b/apps/api/internal/service/cycle.go @@ -264,3 +264,23 @@ func (s *CycleService) GetProgress(ctx context.Context, workspaceSlug string, pr }, }, nil } + +// ProgressBulk returns, per cycle in the project, issue counts grouped by state +// group plus a "total", so the cycles list can render real completion progress. +func (s *CycleService) ProgressBulk(ctx context.Context, workspaceSlug string, projectID, userID uuid.UUID) (map[uuid.UUID]map[string]int, error) { + if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil { + return nil, err + } + dist, err := s.cs.StateDistributionByProject(ctx, projectID) + if err != nil { + return nil, err + } + for _, m := range dist { + total := 0 + for _, c := range m { + total += c + } + m["total"] = total + } + return dist, nil +} diff --git a/apps/api/internal/service/module.go b/apps/api/internal/service/module.go index 96961eeb..81e99937 100644 --- a/apps/api/internal/service/module.go +++ b/apps/api/internal/service/module.go @@ -192,6 +192,27 @@ func (s *ModuleService) Update(ctx context.Context, workspaceSlug string, projec return mod, nil } +// ProgressBulk returns, per module in the project, issue counts grouped by +// state group plus a "total", so the modules list can render real completion +// progress. +func (s *ModuleService) ProgressBulk(ctx context.Context, workspaceSlug string, projectID, userID uuid.UUID) (map[uuid.UUID]map[string]int, error) { + if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil { + return nil, err + } + dist, err := s.ms.StateDistributionByProject(ctx, projectID) + if err != nil { + return nil, err + } + for _, m := range dist { + total := 0 + for _, c := range m { + total += c + } + m["total"] = total + } + return dist, nil +} + func (s *ModuleService) Delete(ctx context.Context, workspaceSlug string, projectID, moduleID uuid.UUID, userID uuid.UUID) error { if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil { return err diff --git a/apps/api/internal/store/cycle.go b/apps/api/internal/store/cycle.go index 7000fd17..3a975b96 100644 --- a/apps/api/internal/store/cycle.go +++ b/apps/api/internal/store/cycle.go @@ -120,6 +120,42 @@ func (s *CycleStore) CycleStateDistribution(ctx context.Context, cycleID uuid.UU return out, nil } +// StateDistributionByProject returns, per cycle in the project, issue counts +// grouped by state group. Used to compute real completion progress on the +// cycles list without a query per cycle. +func (s *CycleStore) StateDistributionByProject(ctx context.Context, projectID uuid.UUID) (map[uuid.UUID]map[string]int, error) { + var rows []struct { + Owner uuid.UUID `gorm:"column:owner"` + Group string `gorm:"column:grp"` + Count int `gorm:"column:count"` + } + err := s.db.WithContext(ctx).Raw(` + SELECT ci.cycle_id AS owner, COALESCE(st."group", 'backlog') AS grp, COUNT(i.id) AS count + FROM cycle_issues ci + JOIN issues i ON i.id = ci.issue_id AND i.deleted_at IS NULL + LEFT JOIN states st ON st.id = i.state_id + WHERE ci.project_id = ? AND ci.deleted_at IS NULL + GROUP BY ci.cycle_id, COALESCE(st."group", 'backlog') + `, projectID).Scan(&rows).Error + if err != nil { + return nil, err + } + out := make(map[uuid.UUID]map[string]int) + for _, r := range rows { + m := out[r.Owner] + if m == nil { + m = map[string]int{"backlog": 0, "unstarted": 0, "started": 0, "completed": 0, "cancelled": 0} + out[r.Owner] = m + } + if _, ok := m[r.Group]; ok { + m[r.Group] += r.Count + } else { + m["backlog"] += r.Count + } + } + return out, nil +} + // CycleCompletionChart returns a date→count map of issues completed per day within the cycle's range. func (s *CycleStore) CycleCompletionChart(ctx context.Context, cycleID uuid.UUID, startDate, endDate *time.Time) (map[string]int, error) { var rows []struct { diff --git a/apps/api/internal/store/module.go b/apps/api/internal/store/module.go index f3937392..8c17e47c 100644 --- a/apps/api/internal/store/module.go +++ b/apps/api/internal/store/module.go @@ -170,6 +170,42 @@ func (s *ModuleStore) ListMemberIDsByModuleIDs(ctx context.Context, moduleIDs [] return out, nil } +// StateDistributionByProject returns, per module in the project, issue counts +// grouped by state group, so the modules list can show real completion +// progress without a query per module. +func (s *ModuleStore) StateDistributionByProject(ctx context.Context, projectID uuid.UUID) (map[uuid.UUID]map[string]int, error) { + var rows []struct { + Owner uuid.UUID `gorm:"column:owner"` + Group string `gorm:"column:grp"` + Count int `gorm:"column:count"` + } + err := s.db.WithContext(ctx).Raw(` + SELECT mi.module_id AS owner, COALESCE(st."group", 'backlog') AS grp, COUNT(i.id) AS count + FROM module_issues mi + JOIN issues i ON i.id = mi.issue_id AND i.deleted_at IS NULL + LEFT JOIN states st ON st.id = i.state_id + WHERE mi.project_id = ? + GROUP BY mi.module_id, COALESCE(st."group", 'backlog') + `, projectID).Scan(&rows).Error + if err != nil { + return nil, err + } + out := make(map[uuid.UUID]map[string]int) + for _, r := range rows { + m := out[r.Owner] + if m == nil { + m = map[string]int{"backlog": 0, "unstarted": 0, "started": 0, "completed": 0, "cancelled": 0} + out[r.Owner] = m + } + if _, ok := m[r.Group]; ok { + m[r.Group] += r.Count + } else { + m["backlog"] += r.Count + } + } + return out, nil +} + func (s *ModuleStore) CountIssuesByModuleIDs(ctx context.Context, moduleIDs []uuid.UUID) (map[uuid.UUID]int, error) { out := make(map[uuid.UUID]int) if len(moduleIDs) == 0 { diff --git a/apps/web/src/pages/CyclesPage.tsx b/apps/web/src/pages/CyclesPage.tsx index 26a0ed4d..1d57c667 100644 --- a/apps/web/src/pages/CyclesPage.tsx +++ b/apps/web/src/pages/CyclesPage.tsx @@ -4,7 +4,11 @@ import { Avatar, Badge, Button, Modal } from '../components/ui'; import { UpdateCycleModal } from '../components/UpdateCycleModal'; import { workspaceService } from '../services/workspaceService'; import { projectService } from '../services/projectService'; -import { cycleService, type CycleProgressResponse } from '../services/cycleService'; +import { + cycleService, + type CycleProgressResponse, + type CycleProgress, +} from '../services/cycleService'; import { CycleBurndownChart } from '../components/cycles/CycleBurndownChart'; import { issueService } from '../services/issueService'; import { stateService } from '../services/stateService'; @@ -319,6 +323,7 @@ export function CyclesPage() { cycleId: string; progress: CycleProgressResponse; } | null>(null); + const [cycleProgress, setCycleProgress] = useState>({}); const [loading, setLoading] = useState(true); const [activeCycleExpanded, setActiveCycleExpanded] = useState(true); const [activeCycleTab, setActiveCycleTab] = useState<'priority' | 'assignees' | 'labels'>( @@ -390,6 +395,23 @@ export function CyclesPage() { }; }, [workspaceSlug, projectId]); + // Real per-cycle completion progress (completed / total by state group). + useEffect(() => { + if (!workspaceSlug || !projectId) return; + let cancelled = false; + cycleService + .listProgress(workspaceSlug, projectId) + .then((p) => { + if (!cancelled) setCycleProgress(p); + }) + .catch(() => { + if (!cancelled) setCycleProgress({}); + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug, projectId]); + useEffect(() => { if (!workspaceSlug || !projectId) return; @@ -594,9 +616,10 @@ export function CyclesPage() { const getIssueCount = (cycleId: string) => cycles.find((c) => c.id === cycleId)?.issue_count ?? 0; const getProgress = (c: CycleApiResponse) => { - const total = getIssueCount(c.id); + const p = cycleProgress[c.id]; + const total = p?.total ?? getIssueCount(c.id); if (!total) return 0; - return c.status === 'completed' ? 100 : 0; + return Math.round(((p?.completed ?? 0) / total) * 100); }; const cyclePath = (c: CycleApiResponse) => workspace && project diff --git a/apps/web/src/pages/ModulesPage.tsx b/apps/web/src/pages/ModulesPage.tsx index 329dd5e5..94b27452 100644 --- a/apps/web/src/pages/ModulesPage.tsx +++ b/apps/web/src/pages/ModulesPage.tsx @@ -6,7 +6,7 @@ import { DateRangeModal } from '../components/workspace-views/DateRangeModal'; import { useModulesFilter } from '../contexts/ModulesFilterContext'; import { workspaceService } from '../services/workspaceService'; import { projectService } from '../services/projectService'; -import { moduleService } from '../services/moduleService'; +import { moduleService, type ModuleProgress } from '../services/moduleService'; import { useModuleFavorites } from '../hooks/useModuleFavorites'; import type { WorkspaceApiResponse, @@ -165,6 +165,7 @@ export function ModulesPage() { const [workspace, setWorkspace] = useState(null); const [project, setProject] = useState(null); const [modules, setModules] = useState([]); + const [moduleProgress, setModuleProgress] = useState>({}); const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); const [timelineTimeframe, setTimelineTimeframe] = useState<'week' | 'month' | 'quarter'>('week'); @@ -268,13 +269,10 @@ export function ModulesPage() { const order = filter.order || 'asc'; const sortedModules = [...filteredModules].sort((a, b) => { const getProgress = (mod: ModuleApiResponse) => { - const total = mod.issue_count ?? 0; + const pr = moduleProgress[mod.id]; + const total = pr?.total ?? mod.issue_count ?? 0; if (!total) return 0; - - // We don't have a completed/cancelled issue breakdown on the module payload. - // Use module status as a simple proxy for sorting and the progress badge. - const done = mod.status === 'completed' || mod.status === 'cancelled' ? total : 0; - return Math.round((done / total) * 100); + return Math.round(((pr?.completed ?? 0) / total) * 100); }; let cmp = 0; switch (sortBy) { @@ -314,6 +312,23 @@ export function ModulesPage() { return () => window.removeEventListener('modules-refresh', handler); }, [workspaceSlug, projectId]); + // Real per-module completion progress (completed / total by state group). + useEffect(() => { + if (!workspaceSlug || !projectId) return; + let cancelled = false; + moduleService + .listProgress(workspaceSlug, projectId) + .then((pr) => { + if (!cancelled) setModuleProgress(pr); + }) + .catch(() => { + if (!cancelled) setModuleProgress({}); + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug, projectId]); + useEffect(() => { const onDoc = (e: MouseEvent) => { const target = e.target as HTMLElement | null; @@ -365,10 +380,10 @@ export function ModulesPage() { }, [workspaceSlug, projectId]); const getProgress = (mod: ModuleApiResponse) => { - const total = mod.issue_count ?? 0; + const pr = moduleProgress[mod.id]; + const total = pr?.total ?? mod.issue_count ?? 0; if (!total) return 0; - const done = mod.status === 'completed' || mod.status === 'cancelled' ? total : 0; - return Math.round((done / total) * 100); + return Math.round(((pr?.completed ?? 0) / total) * 100); }; if (loading) { diff --git a/apps/web/src/services/cycleService.ts b/apps/web/src/services/cycleService.ts index 405b4a1a..439a05c8 100644 --- a/apps/web/src/services/cycleService.ts +++ b/apps/web/src/services/cycleService.ts @@ -15,6 +15,16 @@ export interface UpdateCycleRequest { end_date?: string; } +/** Child-issue counts for a cycle, grouped by state group (plus a total). */ +export interface CycleProgress { + backlog: number; + unstarted: number; + started: number; + completed: number; + cancelled: number; + total: number; +} + export const cycleService = { async list(workspaceSlug: string, projectId: string): Promise { const { data } = await apiClient.get( @@ -23,6 +33,17 @@ export const cycleService = { return data; }, + /** Per-cycle progress for the whole project, keyed by cycle id. */ + async listProgress( + workspaceSlug: string, + projectId: string, + ): Promise> { + const { data } = await apiClient.get>( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/cycles-progress/`, + ); + return data ?? {}; + }, + async create( workspaceSlug: string, projectId: string, diff --git a/apps/web/src/services/moduleService.ts b/apps/web/src/services/moduleService.ts index 5c10bee4..1ee7b833 100644 --- a/apps/web/src/services/moduleService.ts +++ b/apps/web/src/services/moduleService.ts @@ -19,7 +19,28 @@ export interface CreateModulePayload { member_ids?: string[]; } +/** Child-issue counts for a module, grouped by state group (plus a total). */ +export interface ModuleProgress { + backlog: number; + unstarted: number; + started: number; + completed: number; + cancelled: number; + total: number; +} + export const moduleService = { + /** Per-module progress for the whole project, keyed by module id. */ + async listProgress( + workspaceSlug: string, + projectId: string, + ): Promise> { + const { data } = await apiClient.get>( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/modules-progress/`, + ); + return data ?? {}; + }, + async list(workspaceSlug: string, projectId: string): Promise { const { data } = await apiClient.get( `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/modules/`, From c56634ce45745f506437b6204dd0ca59a990449a Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Sun, 5 Jul 2026 22:05:51 +0400 Subject: [PATCH 2/2] fix(ui): refresh cycle progress on the cycles refresh event The PROJECT_CYCLES_REFRESH_EVENT handler reloaded cycles and issues but left cycleProgress stale, so progress could lag after add/remove. Reload it too. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/pages/CyclesPage.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/pages/CyclesPage.tsx b/apps/web/src/pages/CyclesPage.tsx index 1d57c667..072f5d2f 100644 --- a/apps/web/src/pages/CyclesPage.tsx +++ b/apps/web/src/pages/CyclesPage.tsx @@ -466,6 +466,10 @@ export function CyclesPage() { setIssues(iss ?? []); }) .catch(() => {}); + cycleService + .listProgress(workspaceSlug, projectId) + .then(setCycleProgress) + .catch(() => setCycleProgress({})); }; window.addEventListener(PROJECT_CYCLES_REFRESH_EVENT, handler as EventListener); return () => window.removeEventListener(PROJECT_CYCLES_REFRESH_EVENT, handler as EventListener);