diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 32f593e1e..63d8d28ae 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -20,6 +20,7 @@ from django.core import serializers from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page +from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework.response import Response @@ -312,6 +313,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "labels": label_distribution, "completion_chart": {}, } + if data[0]["start_date"] and data[0]["end_date"]: data[0]["distribution"][ "completion_chart" @@ -840,10 +842,230 @@ class TransferCycleIssueEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - new_cycle = Cycle.objects.get( + new_cycle = Cycle.objects.filter( workspace__slug=slug, project_id=project_id, pk=new_cycle_id + ).first() + + old_cycle = ( + Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ) + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + total_estimates=Sum("issue_cycle__issue__estimate_point") + ) + .annotate( + completed_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) ) + # Pass the new_cycle queryset to burndown_plot + completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + cycle_id=cycle_id, + ) + + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_issues=Count( + "id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "id", + filter=Q(archived_at__isnull=True, is_draft=False), + ) + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + assignee_distribution_data = [ + { + "display_name": item["display_name"], + "assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None, + "avatar": item["avatar"], + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in assignee_distribution + ] + + label_distribution_data = [ + { + "label_name": item["label_name"], + "color": item["color"], + "label_id": str(item["label_id"]) if item["label_id"] else None, + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in label_distribution + ] + + current_cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ).first() + + current_cycle.progress_snapshot = { + "total_issues": old_cycle.first().total_issues, + "completed_issues": old_cycle.first().completed_issues, + "cancelled_issues": old_cycle.first().cancelled_issues, + "started_issues": old_cycle.first().started_issues, + "unstarted_issues": old_cycle.first().unstarted_issues, + "backlog_issues": old_cycle.first().backlog_issues, + "total_estimates": old_cycle.first().total_estimates, + "completed_estimates": old_cycle.first().completed_estimates, + "started_estimates": old_cycle.first().started_estimates, + "distribution":{ + "labels": label_distribution_data, + "assignees": assignee_distribution_data, + "completion_chart": completion_chart, + }, + } + current_cycle.save(update_fields=["progress_snapshot"]) + if ( new_cycle.end_date is not None and new_cycle.end_date < timezone.now().date() diff --git a/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py b/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py new file mode 100644 index 000000000..074e20a16 --- /dev/null +++ b/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2024-02-08 09:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0059_auto_20240208_0957'), + ] + + operations = [ + migrations.AddField( + model_name='cycle', + name='progress_snapshot', + field=models.JSONField(default=dict), + ), + ] diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index 5251c68ec..d802dbc1e 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -68,6 +68,7 @@ class Cycle(ProjectBaseModel): sort_order = models.FloatField(default=65535) external_source = models.CharField(max_length=255, null=True, blank=True) external_id = models.CharField(max_length=255, blank=True, null=True) + progress_snapshot = models.JSONField(default=dict) class Meta: verbose_name = "Cycle" diff --git a/packages/types/src/cycles.d.ts b/packages/types/src/cycles.d.ts index 12cbab4c6..5d715385a 100644 --- a/packages/types/src/cycles.d.ts +++ b/packages/types/src/cycles.d.ts @@ -31,6 +31,7 @@ export interface ICycle { issue: string; name: string; owned_by: string; + progress_snapshot: TProgressSnapshot; project: string; project_detail: IProjectLite; status: TCycleGroups; @@ -49,6 +50,23 @@ export interface ICycle { workspace_detail: IWorkspaceLite; } +export type TProgressSnapshot = { + backlog_issues: number; + cancelled_issues: number; + completed_estimates: number | null; + completed_issues: number; + distribution?: { + assignees: TAssigneesDistribution[]; + completion_chart: TCompletionChartDistribution; + labels: TLabelsDistribution[]; + }; + started_estimates: number | null; + started_issues: number; + total_estimates: number | null; + total_issues: number; + unstarted_issues: number; +}; + export type TAssigneesDistribution = { assignee_id: string | null; avatar: string | null; diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 5dd2923a8..37aba932a 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -54,10 +54,6 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { setIsOpen(false); }; - const handleOnChange = () => { - if (closeOnSelect) closeDropdown(); - }; - const selectActiveItem = () => { const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector( `[data-headlessui-state="active"] button` diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index 299c71008..6966779b5 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { useForm } from "react-hook-form"; import { Disclosure, Popover, Transition } from "@headlessui/react"; +import isEmpty from "lodash/isEmpty"; // services import { CycleService } from "services/cycle.service"; // hooks @@ -293,7 +294,11 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const isEndValid = new Date(`${cycleDetails?.end_date}`) >= new Date(`${cycleDetails?.start_date}`); const progressPercentage = cycleDetails - ? Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100) + ? isCompleted + ? Math.round( + (cycleDetails.progress_snapshot.completed_issues / cycleDetails.progress_snapshot.total_issues) * 100 + ) + : Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100) : null; if (!cycleDetails) @@ -317,7 +322,14 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); const issueCount = - cycleDetails.total_issues === 0 ? "0 Issue" : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + isCompleted && !isEmpty(cycleDetails.progress_snapshot) + ? cycleDetails.progress_snapshot.total_issues === 0 + ? "0 Issue" + : `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}` + : cycleDetails.total_issues === 0 + ? "0 Issue" + : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + const daysLeft = findHowManyDaysLeft(cycleDetails.end_date); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; @@ -568,49 +580,105 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
- {cycleDetails.distribution?.completion_chart && - cycleDetails.start_date && - cycleDetails.end_date ? ( -
-
-
-
- - Ideal + {isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? ( + <> + {cycleDetails.progress_snapshot.distribution?.completion_chart && + cycleDetails.start_date && + cycleDetails.end_date && ( +
+
+
+
+ + Ideal +
+
+ + Current +
+
+
+
+ +
-
- - Current -
-
-
-
- -
-
+ )} + ) : ( - "" + <> + {cycleDetails.distribution?.completion_chart && + cycleDetails.start_date && + cycleDetails.end_date && ( +
+
+
+
+ + Ideal +
+
+ + Current +
+
+
+
+ +
+
+ )} + )} - {cycleDetails.total_issues > 0 && cycleDetails.distribution && ( -
- -
+ {/* stats */} + {isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? ( + <> + {cycleDetails.progress_snapshot.total_issues > 0 && + cycleDetails.progress_snapshot.distribution && ( +
+ +
+ )} + + ) : ( + <> + {cycleDetails.total_issues > 0 && cycleDetails.distribution && ( +
+ +
+ )} + )}