diff --git a/apps/web/core/components/cycles/list/cycles-list-item.tsx b/apps/web/core/components/cycles/list/cycles-list-item.tsx index 18e6d90bb..0e17743bb 100644 --- a/apps/web/core/components/cycles/list/cycles-list-item.tsx +++ b/apps/web/core/components/cycles/list/cycles-list-item.tsx @@ -8,7 +8,7 @@ import { Check } from "lucide-react"; import type { TCycleGroups } from "@plane/types"; import { CircularProgressIndicator } from "@plane/ui"; // components -import { generateQueryParams } from "@plane/utils"; +import { generateQueryParams, calculateCycleProgress } from "@plane/utils"; import { ListItem } from "@/components/core/list"; // hooks import { useCycle } from "@/hooks/store/use-cycle"; @@ -50,7 +50,6 @@ export const CyclesListItem: FC = observer((props) => { // computed // TODO: change this logic once backend fix the response const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; - const isCompleted = cycleStatus === "completed"; const isActive = cycleStatus === "current"; // handlers @@ -73,20 +72,7 @@ export const CyclesListItem: FC = observer((props) => { const handleItemClick = cycleDetails.archived_at ? handleArchivedCycleClick : undefined; - const getCycleProgress = () => { - let completionPercentage = - ((cycleDetails.completed_issues + cycleDetails.cancelled_issues) / cycleDetails.total_issues) * 100; - - if (isCompleted && !isEmpty(cycleDetails.progress_snapshot)) { - completionPercentage = - ((cycleDetails.progress_snapshot.completed_issues + cycleDetails.progress_snapshot.cancelled_issues) / - cycleDetails.progress_snapshot.total_issues) * - 100; - } - return isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); - }; - - const progress = getCycleProgress(); + const progress = calculateCycleProgress(cycleDetails); return ( = observer((props) => { className={className} prependTitleElement={ - {isCompleted ? ( - progress === 100 ? ( - - ) : ( - {`!`} - ) - ) : progress === 100 ? ( + {progress === 100 ? ( ) : ( - {`${progress}%`} + {`${progress}%`} )} } diff --git a/packages/utils/src/cycle.ts b/packages/utils/src/cycle.ts index 133db1595..2f47f223f 100644 --- a/packages/utils/src/cycle.ts +++ b/packages/utils/src/cycle.ts @@ -4,7 +4,7 @@ import orderBy from "lodash/orderBy"; import sortBy from "lodash/sortBy"; import uniqBy from "lodash/uniqBy"; // plane imports -import { ICycle, TCycleFilters } from "@plane/types"; +import { ICycle, TCycleFilters, TProgressSnapshot } from "@plane/types"; // local imports import { findTotalDaysInRange, generateDateArray, getDate } from "./datetime"; import { satisfiesDateFilter } from "./filter"; @@ -191,3 +191,57 @@ export const formatActiveCycle = (args: { ? formatV1Data(isTypeIssue!, cycle, isBurnDown!, endDate) : formatV2Data(isTypeIssue!, cycle, isBurnDown!, endDate); }; + +/** + * Calculates cycle progress percentage excluding cancelled issues from total count + * Formula: completed / (total - cancelled) * 100 + * This gives accurate progress based on: pendingIssues = totalIssues - completedIssues - cancelledIssues + * @param cycle - Cycle data object + * @param estimateType - Whether to calculate based on "issues" or "points" + * @param includeInProgress - Whether to include started/in-progress items in completion calculation + * @returns Progress percentage (0-100) + */ +export const calculateCycleProgress = ( + cycle: ICycle | undefined, + estimateType: "issues" | "points" = "issues", + includeInProgress: boolean = false +): number => { + if (!cycle) return 0; + + const progressSnapshot: TProgressSnapshot | undefined = cycle.progress_snapshot; + const cycleDetails = progressSnapshot && !isEmpty(progressSnapshot) ? progressSnapshot : cycle; + + let completed: number; + let cancelled: number; + let total: number; + + if (estimateType === "points") { + completed = cycleDetails.completed_estimate_points || 0; + cancelled = cycleDetails.cancelled_estimate_points || 0; + total = cycleDetails.total_estimate_points || 0; + + if (includeInProgress) { + completed += cycleDetails.started_estimate_points || 0; + } + } else { + completed = cycleDetails.completed_issues || 0; + cancelled = cycleDetails.cancelled_issues || 0; + total = cycleDetails.total_issues || 0; + + if (includeInProgress) { + completed += cycleDetails.started_issues || 0; + } + } + + // Exclude cancelled issues from total (pendingIssues = total - completed - cancelled) + const adjustedTotal = total - cancelled; + + // Handle edge cases + if (adjustedTotal === 0) return 0; + if (completed < 0 || adjustedTotal < 0) return 0; + if (completed > adjustedTotal) return 100; + + // Calculate percentage and round + const percentage = (completed / adjustedTotal) * 100; + return Math.round(percentage); +};