Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/api/internal/handler/cycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions apps/api/internal/handler/cycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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_RejectsInvalidDates(t *testing.T) {
ts := testutil.NewTestServer(t)
w := testutil.SeedWorld(t, ts.DB)
Expand Down
31 changes: 31 additions & 0 deletions apps/api/internal/handler/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,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)
Expand All @@ -395,6 +396,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)
Expand Down
20 changes: 20 additions & 0 deletions apps/api/internal/service/cycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,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
}
21 changes: 21 additions & 0 deletions apps/api/internal/service/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,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
Expand Down
36 changes: 36 additions & 0 deletions apps/api/internal/store/cycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,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 {
Expand Down
36 changes: 36 additions & 0 deletions apps/api/internal/store/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 30 additions & 3 deletions apps/web/src/pages/CyclesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -319,6 +323,7 @@ export function CyclesPage() {
cycleId: string;
progress: CycleProgressResponse;
} | null>(null);
const [cycleProgress, setCycleProgress] = useState<Record<string, CycleProgress>>({});
const [loading, setLoading] = useState(true);
const [activeCycleExpanded, setActiveCycleExpanded] = useState(true);
const [activeCycleTab, setActiveCycleTab] = useState<'priority' | 'assignees' | 'labels'>(
Expand Down Expand Up @@ -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]);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
useEffect(() => {
if (!workspaceSlug || !projectId) return;

Expand Down Expand Up @@ -444,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);
Expand Down Expand Up @@ -594,9 +620,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
Expand Down
Loading
Loading