diff --git a/apiserver/plane/app/urls/cycle.py b/apiserver/plane/app/urls/cycle.py index ce2e0f6dcce..0d62d0271da 100644 --- a/apiserver/plane/app/urls/cycle.py +++ b/apiserver/plane/app/urls/cycle.py @@ -6,6 +6,8 @@ CycleIssueViewSet, CycleDateCheckEndpoint, CycleFavoriteViewSet, + CycleProgressEndpoint, + CycleAnalyticsEndpoint, TransferCycleIssueEndpoint, CycleUserPropertiesEndpoint, CycleArchiveUnarchiveEndpoint, @@ -106,4 +108,14 @@ CycleArchiveUnarchiveEndpoint.as_view(), name="cycle-archive-unarchive", ), + path( + "workspaces//projects//cycles//progress/", + CycleProgressEndpoint.as_view(), + name="project-cycle", + ), + path( + "workspaces//projects//cycles//analytics/", + CycleAnalyticsEndpoint.as_view(), + name="project-cycle", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 5568542f70a..f3dd1a21e39 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -98,6 +98,8 @@ CycleUserPropertiesEndpoint, CycleViewSet, TransferCycleIssueEndpoint, + CycleAnalyticsEndpoint, + CycleProgressEndpoint, ) from .cycle.issue import ( CycleIssueViewSet, diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 86f9de0ef62..ecc83630f21 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -17,7 +17,6 @@ UUIDField, Value, When, - Subquery, Sum, FloatField, ) @@ -28,9 +27,7 @@ # Third party imports from rest_framework import status from rest_framework.response import Response -from plane.app.permissions import ( - allow_permission, ROLE -) +from plane.app.permissions import allow_permission, ROLE from plane.app.serializers import ( CycleSerializer, CycleUserPropertiesSerializer, @@ -69,89 +66,6 @@ def get_queryset(self): project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), ) - backlog_estimate_point = ( - Issue.issue_objects.filter( - estimate_point__estimate__type="points", - state__group="backlog", - issue_cycle__cycle_id=OuterRef("pk"), - ) - .values("issue_cycle__cycle_id") - .annotate( - backlog_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) - .values("backlog_estimate_point")[:1] - ) - unstarted_estimate_point = ( - Issue.issue_objects.filter( - estimate_point__estimate__type="points", - state__group="unstarted", - issue_cycle__cycle_id=OuterRef("pk"), - ) - .values("issue_cycle__cycle_id") - .annotate( - unstarted_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) - .values("unstarted_estimate_point")[:1] - ) - started_estimate_point = ( - Issue.issue_objects.filter( - estimate_point__estimate__type="points", - state__group="started", - issue_cycle__cycle_id=OuterRef("pk"), - ) - .values("issue_cycle__cycle_id") - .annotate( - started_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) - .values("started_estimate_point")[:1] - ) - cancelled_estimate_point = ( - Issue.issue_objects.filter( - estimate_point__estimate__type="points", - state__group="cancelled", - issue_cycle__cycle_id=OuterRef("pk"), - ) - .values("issue_cycle__cycle_id") - .annotate( - cancelled_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) - .values("cancelled_estimate_point")[:1] - ) - completed_estimate_point = ( - Issue.issue_objects.filter( - estimate_point__estimate__type="points", - state__group="completed", - issue_cycle__cycle_id=OuterRef("pk"), - ) - .values("issue_cycle__cycle_id") - .annotate( - completed_estimate_points=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) - .values("completed_estimate_points")[:1] - ) - total_estimate_point = ( - Issue.issue_objects.filter( - estimate_point__estimate__type="points", - issue_cycle__cycle_id=OuterRef("pk"), - ) - .values("issue_cycle__cycle_id") - .annotate( - total_estimate_points=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) - .values("total_estimate_points")[:1] - ) return self.filter_queryset( super() .get_queryset() @@ -201,50 +115,6 @@ def get_queryset(self): ), ) ) - .annotate( - cancelled_issues=Count( - "issue_cycle__issue__id", - distinct=True, - 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__id", - distinct=True, - 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__id", - distinct=True, - 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__id", - distinct=True, - filter=Q( - issue_cycle__issue__state__group="backlog", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) .annotate( status=Case( When( @@ -276,42 +146,6 @@ def get_queryset(self): Value([], output_field=ArrayField(UUIDField())), ) ) - .annotate( - backlog_estimate_points=Coalesce( - Subquery(backlog_estimate_point), - Value(0, output_field=FloatField()), - ), - ) - .annotate( - unstarted_estimate_points=Coalesce( - Subquery(unstarted_estimate_point), - Value(0, output_field=FloatField()), - ), - ) - .annotate( - started_estimate_points=Coalesce( - Subquery(started_estimate_point), - Value(0, output_field=FloatField()), - ), - ) - .annotate( - cancelled_estimate_points=Coalesce( - Subquery(cancelled_estimate_point), - Value(0, output_field=FloatField()), - ), - ) - .annotate( - completed_estimate_points=Coalesce( - Subquery(completed_estimate_point), - Value(0, output_field=FloatField()), - ), - ) - .annotate( - total_estimate_points=Coalesce( - Subquery(total_estimate_point), - Value(0, output_field=FloatField()), - ), - ) .order_by("-is_favorite", "name") .distinct() ) @@ -348,227 +182,16 @@ def list(self, request, slug, project_id): "external_id", "progress_snapshot", "logo_props", - # meta fields - "backlog_estimate_points", - "unstarted_estimate_points", - "started_estimate_points", - "cancelled_estimate_points", - "completed_estimate_points", - "total_estimate_points", "is_favorite", "total_issues", - "cancelled_issues", "completed_issues", - "started_issues", - "unstarted_issues", - "backlog_issues", "assignee_ids", "status", "created_by", ) - estimate_type = Project.objects.filter( - workspace__slug=slug, - pk=project_id, - estimate__isnull=False, - estimate__type="points", - ).exists() if data: - data[0]["estimate_distribution"] = {} - if estimate_type: - assignee_distribution = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=data[0]["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_estimates=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) - .annotate( - completed_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("display_name") - ) - - label_distribution = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=data[0]["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_estimates=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) - .annotate( - completed_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - data[0]["estimate_distribution"] = { - "assignees": assignee_distribution, - "labels": label_distribution, - "completion_chart": {}, - } - - if data[0]["start_date"] and data[0]["end_date"]: - data[0]["estimate_distribution"][ - "completion_chart" - ] = burndown_plot( - queryset=queryset.first(), - slug=slug, - project_id=project_id, - plot_type="points", - cycle_id=data[0]["id"], - ) - - assignee_distribution = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=data[0]["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.issue_objects.filter( - issue_cycle__cycle_id=data[0]["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") - ) - data[0]["distribution"] = { - "assignees": assignee_distribution, - "labels": label_distribution, - "completion_chart": {}, - } - - if data[0]["start_date"] and data[0]["end_date"]: - data[0]["distribution"]["completion_chart"] = ( - burndown_plot( - queryset=queryset.first(), - slug=slug, - project_id=project_id, - plot_type="issues", - cycle_id=data[0]["id"], - ) - ) - - return Response(data, status=status.HTTP_200_OK) + return Response(data, status=status.HTTP_200_OK) data = queryset.values( # necessary fields @@ -588,15 +211,9 @@ def list(self, request, slug, project_id): "progress_snapshot", "logo_props", # meta fields - "completed_estimate_points", - "total_estimate_points", "is_favorite", "total_issues", - "cancelled_issues", "completed_issues", - "started_issues", - "unstarted_issues", - "backlog_issues", "assignee_ids", "status", "created_by", @@ -639,15 +256,9 @@ def create(self, request, slug, project_id): "progress_snapshot", "logo_props", # meta fields - "completed_estimate_points", - "total_estimate_points", "is_favorite", - "cancelled_issues", "total_issues", "completed_issues", - "started_issues", - "unstarted_issues", - "backlog_issues", "assignee_ids", "status", "created_by", @@ -737,15 +348,9 @@ def partial_update(self, request, slug, project_id, pk): "progress_snapshot", "logo_props", # meta fields - "completed_estimate_points", - "total_estimate_points", "is_favorite", "total_issues", - "cancelled_issues", "completed_issues", - "started_issues", - "unstarted_issues", - "backlog_issues", "assignee_ids", "status", "created_by", @@ -802,232 +407,16 @@ def retrieve(self, request, slug, project_id, pk): "sub_issues", "logo_props", # meta fields - "completed_estimate_points", - "total_estimate_points", "is_favorite", "total_issues", - "cancelled_issues", - "completed_issues", - "started_issues", - "unstarted_issues", - "backlog_issues", - "assignee_ids", - "status", - "created_by", - ) - .first() - ) - queryset = queryset.first() - - estimate_type = Project.objects.filter( - workspace__slug=slug, - pk=project_id, - estimate__isnull=False, - estimate__type="points", - ).exists() - - data["estimate_distribution"] = {} - if estimate_type: - assignee_distribution = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=pk, - 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_estimates=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) - .annotate( - completed_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("display_name") - ) - - label_distribution = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=pk, - 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_estimates=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) - .annotate( - completed_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - data["estimate_distribution"] = { - "assignees": assignee_distribution, - "labels": label_distribution, - "completion_chart": {}, - } - - if data["start_date"] and data["end_date"]: - data["estimate_distribution"]["completion_chart"] = ( - burndown_plot( - queryset=queryset, - slug=slug, - project_id=project_id, - plot_type="points", - cycle_id=pk, - ) - ) - - # Assignee Distribution - assignee_distribution = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=pk, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(first_name=F("assignees__first_name")) - .annotate(last_name=F("assignees__last_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .annotate(display_name=F("assignees__display_name")) - .values( - "first_name", - "last_name", - "assignee_id", - "avatar", - "display_name", - ) - .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("first_name", "last_name") - ) - - # Label Distribution - label_distribution = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=pk, - 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") - ) - - data["distribution"] = { - "assignees": assignee_distribution, - "labels": label_distribution, - "completion_chart": {}, - } - - if queryset.start_date and queryset.end_date: - data["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset, - slug=slug, - project_id=project_id, - plot_type="issues", - cycle_id=pk, + "completed_issues", + "assignee_ids", + "status", + "created_by", ) + .first() + ) + queryset = queryset.first() recent_visited_task.delay( slug=slug, @@ -1614,3 +1003,360 @@ def get(self, request, slug, project_id, cycle_id): ) serializer = CycleUserPropertiesSerializer(cycle_properties) return Response(serializer.data, status=status.HTTP_200_OK) + + +class CycleProgressEndpoint(BaseAPIView): + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]) + def get(self, request, slug, project_id, cycle_id): + + aggregate_estimates = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate( + value_as_float=Cast("estimate_point__value", FloatField()) + ) + .aggregate( + backlog_estimate_point=Sum( + Case( + When(state__group="backlog", then="value_as_float"), + default=Value(0), + output_field=FloatField(), + ) + ), + unstarted_estimate_point=Sum( + Case( + When(state__group="unstarted", then="value_as_float"), + default=Value(0), + output_field=FloatField(), + ) + ), + started_estimate_point=Sum( + Case( + When(state__group="started", then="value_as_float"), + default=Value(0), + output_field=FloatField(), + ) + ), + cancelled_estimate_point=Sum( + Case( + When(state__group="cancelled", then="value_as_float"), + default=Value(0), + output_field=FloatField(), + ) + ), + completed_estimate_points=Sum( + Case( + When(state__group="completed", then="value_as_float"), + default=Value(0), + output_field=FloatField(), + ) + ), + total_estimate_points=Sum( + "value_as_float", + default=Value(0), + output_field=FloatField(), + ), + ) + ) + + backlog_issues = Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + state__group="backlog", + ).count() + + unstarted_issues = Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + state__group="unstarted", + ).count() + + started_issues = Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + state__group="started", + ).count() + + cancelled_issues = Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + state__group="cancelled", + ).count() + + completed_issues = Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + state__group="completed", + ).count() + + total_issues = Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ).count() + + return Response( + { + "backlog_estimate_points": aggregate_estimates[ + "backlog_estimate_point" + ] + or 0, + "unstarted_estimate_points": aggregate_estimates[ + "unstarted_estimate_point" + ] + or 0, + "started_estimate_points": aggregate_estimates[ + "started_estimate_point" + ] + or 0, + "cancelled_estimate_points": aggregate_estimates[ + "cancelled_estimate_point" + ] + or 0, + "completed_estimate_points": aggregate_estimates[ + "completed_estimate_points" + ] + or 0, + "total_estimate_points": aggregate_estimates[ + "total_estimate_points" + ], + "backlog_issues": backlog_issues, + "total_issues": total_issues, + "completed_issues": completed_issues, + "cancelled_issues": cancelled_issues, + "started_issues": started_issues, + "unstarted_issues": unstarted_issues, + }, + status=status.HTTP_200_OK, + ) + + +class CycleAnalyticsEndpoint(BaseAPIView): + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]) + def get(self, request, slug, project_id, cycle_id): + analytic_type = request.GET.get("type", "issues") + cycle = ( + Cycle.objects.filter( + workspace__slug=slug, + project_id=project_id, + id=cycle_id, + ) + .annotate( + total_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .first() + ) + + if not cycle.start_date or not cycle.end_date: + return Response( + {"error": "Cycle has no start or end date"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + assignee_distribution = {} + label_distribution = {} + completion_chart = {} + + if analytic_type == "points" and estimate_type: + assignee_distribution = ( + Issue.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_estimates=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + label_distribution = ( + Issue.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_estimates=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + completion_chart = burndown_plot( + queryset=cycle, + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=cycle_id, + ) + + if analytic_type == "issues": + assignee_distribution = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + project_id=project_id, + workspace__slug=slug, + ) + .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( + "assignee_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "assignee_id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "assignee_id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + label_distribution = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + project_id=project_id, + workspace__slug=slug, + ) + .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( + "label_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ) + ) + .annotate( + completed_issues=Count( + "label_id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "label_id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + completion_chart = burndown_plot( + queryset=cycle, + slug=slug, + project_id=project_id, + cycle_id=cycle_id, + plot_type="issues", + ) + + return Response( + { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": completion_chart, + }, + status=status.HTTP_200_OK, + ) diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx index e63cefa098d..6f31416c8cb 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx @@ -2,11 +2,11 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import useSWR from "swr"; // components import { EmptyState } from "@/components/common"; import { PageHead } from "@/components/core"; import { CycleDetailsSidebar } from "@/components/cycles"; +import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details"; import { CycleLayoutRoot } from "@/components/issues/issue-layouts"; // constants // import { EIssuesStoreType } from "@/constants/issue"; @@ -24,18 +24,17 @@ const CycleDetailPage = observer(() => { const router = useAppRouter(); const { workspaceSlug, projectId, cycleId } = useParams(); // store hooks - const { fetchCycleDetails, getCycleById } = useCycle(); + const { getCycleById, loader } = useCycle(); const { getProjectById } = useProject(); // const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE); // hooks const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false"); - // fetching cycle details - const { error } = useSWR( - workspaceSlug && projectId && cycleId ? `CYCLE_DETAILS_${cycleId.toString()}` : null, - workspaceSlug && projectId && cycleId - ? () => fetchCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString()) - : null - ); + + useCyclesDetails({ + workspaceSlug: workspaceSlug.toString(), + projectId: projectId.toString(), + cycleId: cycleId.toString(), + }); // derived values const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; const cycle = cycleId ? getCycleById(cycleId.toString()) : undefined; @@ -52,7 +51,7 @@ const CycleDetailPage = observer(() => { return ( <> - {error ? ( + {!cycle && !loader ? ( void; + cycleIssueDetails: ActiveCycleIssueDetails; }; export const ActiveCycleStats: FC = observer((props) => { - const { workspaceSlug, projectId, cycle, cycleId, handleFiltersUpdate } = props; + const { workspaceSlug, projectId, cycle, cycleId, handleFiltersUpdate, cycleIssueDetails } = props; const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees"); @@ -57,21 +58,12 @@ export const ActiveCycleStats: FC = observer((props) => { } }; const { - issues: { getActiveCycleById, fetchActiveCycleIssues, fetchNextActiveCycleIssues }, + issues: { fetchNextActiveCycleIssues }, } = useIssues(EIssuesStoreType.CYCLE); const { issue: { getIssueById }, setPeekIssue, } = useIssueDetail(); - - useSWR( - workspaceSlug && projectId && cycleId ? CYCLE_ISSUES_WITH_PARAMS(cycleId, { priority: "urgent,high" }) : null, - workspaceSlug && projectId && cycleId ? () => fetchActiveCycleIssues(workspaceSlug, projectId, 30, cycleId) : null, - { revalidateIfStale: false, revalidateOnFocus: false } - ); - - const cycleIssueDetails = cycleId ? getActiveCycleById(cycleId) : { nextPageResults: false }; - const loadMoreIssues = useCallback(() => { if (!cycleId) return; fetchNextActiveCycleIssues(workspaceSlug, projectId, cycleId); @@ -87,6 +79,7 @@ export const ActiveCycleStats: FC = observer((props) => { ); + return cycleId ? (
= observer((props) => { as="div" className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm" > - {cycle ? ( + {cycle && !isEmpty(cycle.distribution) ? ( cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? ( cycle.distribution?.assignees?.map((assignee, index) => { if (assignee.assignee_id) @@ -306,7 +299,7 @@ export const ActiveCycleStats: FC = observer((props) => { as="div" className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm" > - {cycle ? ( + {cycle && !isEmpty(cycle.distribution) ? ( cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? ( cycle.distribution.labels?.map((label, index) => ( = observer((props) => { const { workspaceSlug, projectId, cycle } = props; // hooks - const { getPlotTypeByCycleId, setPlotType, fetchCycleDetails } = useCycle(); + const { getPlotTypeByCycleId, setPlotType } = useCycle(); const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates(); - // state - const [loader, setLoader] = useState(false); + // derived values const plotType: TCyclePlotType = (cycle && getPlotTypeByCycleId(cycle.id)) || "burndown"; const onChange = async (value: TCyclePlotType) => { if (!workspaceSlug || !projectId || !cycle || !cycle.id) return; setPlotType(cycle.id, value); - try { - setLoader(true); - await fetchCycleDetails(workspaceSlug, projectId, cycle.id); - setLoader(false); - } catch (error) { - setLoader(false); - setPlotType(cycle.id, plotType); - } }; const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false; @@ -55,7 +46,7 @@ export const ActiveCycleProductivity: FC = observe cycle && plotType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined; const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; - return cycle ? ( + return cycle && completionChartDistributionData ? (
@@ -75,7 +66,6 @@ export const ActiveCycleProductivity: FC = observe ))} - {loader && }
)}
diff --git a/web/core/components/cycles/active-cycle/progress.tsx b/web/core/components/cycles/active-cycle/progress.tsx index fc6e86561a0..f75c51526c4 100644 --- a/web/core/components/cycles/active-cycle/progress.tsx +++ b/web/core/components/cycles/active-cycle/progress.tsx @@ -16,31 +16,33 @@ import { useProjectState } from "@/hooks/store"; export type ActiveCycleProgressProps = { cycle: ICycle | null; + workspaceSlug: string; + projectId: string; handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void; }; export const ActiveCycleProgress: FC = observer((props) => { - const { cycle, handleFiltersUpdate } = props; + const { handleFiltersUpdate, cycle } = props; // store hooks const { groupedProjectStates } = useProjectState(); + // derived values const progressIndicatorData = PROGRESS_STATE_GROUPS_DETAILS.map((group, index) => ({ id: index, name: group.title, value: cycle && cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0, color: group.color, })); - const groupedIssues: any = cycle ? { - completed: cycle.completed_issues, - started: cycle.started_issues, - unstarted: cycle.unstarted_issues, - backlog: cycle.backlog_issues, + completed: cycle?.completed_issues, + started: cycle?.started_issues, + unstarted: cycle?.unstarted_issues, + backlog: cycle?.backlog_issues, } : {}; - return cycle ? ( + return cycle && cycle.hasOwnProperty("started_issues") ? (
diff --git a/web/core/components/cycles/active-cycle/root.tsx b/web/core/components/cycles/active-cycle/root.tsx index 610de473c0f..00be0e34fa2 100644 --- a/web/core/components/cycles/active-cycle/root.tsx +++ b/web/core/components/cycles/active-cycle/root.tsx @@ -1,15 +1,7 @@ "use client"; -import { useCallback } from "react"; -import isEqual from "lodash/isEqual"; import { observer } from "mobx-react"; -import { useRouter } from "next/navigation"; -import useSWR from "swr"; import { Disclosure } from "@headlessui/react"; -// types -import { IIssueFilterOptions } from "@plane/types"; -// ui -import { Loader } from "@plane/ui"; // components import { ActiveCycleProductivity, @@ -21,9 +13,9 @@ import { import { EmptyState } from "@/components/empty-state"; // constants import { EmptyStateType } from "@/constants/empty-state"; -import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; -// hooks -import { useCycle, useIssues } from "@/hooks/store"; +import { useCycle } from "@/hooks/store"; +import { ActiveCycleIssueDetails } from "@/store/issue/cycle"; +import useCyclesDetails from "./use-cycles-details"; interface IActiveCycleDetails { workspaceSlug: string; @@ -31,56 +23,13 @@ interface IActiveCycleDetails { } export const ActiveCycleRoot: React.FC = observer((props) => { - // props const { workspaceSlug, projectId } = props; - // router - const router = useRouter(); - // store hooks + const { currentProjectActiveCycle, currentProjectActiveCycleId } = useCycle(); const { - issuesFilter: { issueFilters, updateFilters }, - } = useIssues(EIssuesStoreType.CYCLE); - const { currentProjectActiveCycle, fetchActiveCycle, currentProjectActiveCycleId, getActiveCycleById } = useCycle(); - // derived values - const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null; - // fetch active cycle details - const { isLoading } = useSWR( - workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null, - workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null - ); - - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => { - if (!workspaceSlug || !projectId || !currentProjectActiveCycleId) return; - - const newFilters: IIssueFilterOptions = {}; - Object.keys(issueFilters?.filters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = []; - }); - - let newValues: string[] = []; - - if (isEqual(newValues, value)) newValues = []; - else newValues = value; - - updateFilters( - workspaceSlug.toString(), - projectId.toString(), - EIssueFilterType.FILTERS, - { ...newFilters, [key]: newValues }, - currentProjectActiveCycleId.toString() - ); - if (redirect) router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${currentProjectActiveCycleId}`); - }, - [workspaceSlug, projectId, currentProjectActiveCycleId, issueFilters, updateFilters, router] - ); - - // show loader if active cycle is loading - if (!currentProjectActiveCycle && isLoading) - return ( - - - - ); + handleFiltersUpdate, + cycle: activeCycle, + cycleIssueDetails, + } = useCyclesDetails({ workspaceSlug, projectId, cycleId: currentProjectActiveCycleId }); return ( <> @@ -106,7 +55,12 @@ export const ActiveCycleRoot: React.FC = observer((props) = )}
- + = observer((props) = cycle={activeCycle} cycleId={currentProjectActiveCycleId} handleFiltersUpdate={handleFiltersUpdate} + cycleIssueDetails={cycleIssueDetails as ActiveCycleIssueDetails} />
diff --git a/web/core/components/cycles/active-cycle/use-cycles-details.ts b/web/core/components/cycles/active-cycle/use-cycles-details.ts new file mode 100644 index 00000000000..fa0ed01eac2 --- /dev/null +++ b/web/core/components/cycles/active-cycle/use-cycles-details.ts @@ -0,0 +1,94 @@ +import { useCallback } from "react"; +import isEqual from "lodash/isEqual"; +import { useRouter } from "next/navigation"; +import useSWR from "swr"; +import { IIssueFilterOptions } from "@plane/types"; +import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys"; +import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; +import { useCycle, useIssues } from "@/hooks/store"; + +interface IActiveCycleDetails { + workspaceSlug: string; + projectId: string; + cycleId: string | null; +} + +const useCyclesDetails = (props: IActiveCycleDetails) => { + // props + const { workspaceSlug, projectId, cycleId } = props; + // router + const router = useRouter(); + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + issues: { getActiveCycleById: getActiveCycleByIdFromIssue, fetchActiveCycleIssues }, + } = useIssues(EIssuesStoreType.CYCLE); + + const { fetchActiveCycleProgress, getCycleById, fetchActiveCycleAnalytics } = useCycle(); + // derived values + const cycle = cycleId ? getCycleById(cycleId) : null; + + // fetch cycle details + useSWR( + workspaceSlug && projectId && cycle ? `PROJECT_ACTIVE_CYCLE_${projectId}_PROGRESS` : null, + workspaceSlug && projectId && cycle ? () => fetchActiveCycleProgress(workspaceSlug, projectId, cycle.id) : null, + { revalidateIfStale: false, revalidateOnFocus: false } + ); + useSWR( + workspaceSlug && projectId && cycle && !cycle?.distribution ? `PROJECT_ACTIVE_CYCLE_${projectId}_DURATION` : null, + workspaceSlug && projectId && cycle && !cycle?.distribution + ? () => fetchActiveCycleAnalytics(workspaceSlug, projectId, cycle.id, "issues") + : null + ); + useSWR( + workspaceSlug && projectId && cycle && !cycle?.estimate_distribution + ? `PROJECT_ACTIVE_CYCLE_${projectId}_ESTIMATE_DURATION` + : null, + workspaceSlug && projectId && cycle && !cycle?.estimate_distribution + ? () => fetchActiveCycleAnalytics(workspaceSlug, projectId, cycle.id, "points") + : null + ); + useSWR( + workspaceSlug && projectId && cycle?.id ? CYCLE_ISSUES_WITH_PARAMS(cycle?.id, { priority: "urgent,high" }) : null, + workspaceSlug && projectId && cycle?.id + ? () => fetchActiveCycleIssues(workspaceSlug, projectId, 30, cycle?.id) + : null, + { revalidateIfStale: false, revalidateOnFocus: false } + ); + + const cycleIssueDetails = cycle?.id ? getActiveCycleByIdFromIssue(cycle?.id) : { nextPageResults: false }; + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => { + if (!workspaceSlug || !projectId || !cycleId) return; + + const newFilters: IIssueFilterOptions = {}; + Object.keys(issueFilters?.filters ?? {}).forEach((key) => { + newFilters[key as keyof IIssueFilterOptions] = []; + }); + + let newValues: string[] = []; + + if (isEqual(newValues, value)) newValues = []; + else newValues = value; + + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.FILTERS, + { ...newFilters, [key]: newValues }, + cycleId.toString() + ); + if (redirect) router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`); + }, + [workspaceSlug, projectId, cycleId, issueFilters, updateFilters, router] + ); + return { + cycle, + cycleId, + router, + handleFiltersUpdate, + cycleIssueDetails, + }; +}; +export default useCyclesDetails; diff --git a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx index a78974c6195..ea44cce8156 100644 --- a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx +++ b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx @@ -8,7 +8,7 @@ import { useSearchParams } from "next/navigation"; import { AlertCircle, ChevronUp, ChevronDown } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; import { ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types"; -import { CustomSelect, Spinner } from "@plane/ui"; +import { CustomSelect, Loader, Spinner } from "@plane/ui"; // components import ProgressChart from "@/components/core/sidebar/progress-chart"; import { CycleProgressStats } from "@/components/cycles"; @@ -231,7 +231,7 @@ export const CycleAnalyticsProgress: FC = observer((pro Current
- {cycleStartDate && cycleEndDate && completionChartDistributionData && ( + {cycleStartDate && cycleEndDate && completionChartDistributionData ? ( {plotType === "points" ? ( = observer((pro /> )} + ) : ( + + + )}
diff --git a/web/core/services/cycle.service.ts b/web/core/services/cycle.service.ts index 5d4b9d7b8b3..be8a25281b2 100644 --- a/web/core/services/cycle.service.ts +++ b/web/core/services/cycle.service.ts @@ -4,8 +4,9 @@ import type { ICycle, TIssuesResponse, IWorkspaceActiveCyclesResponse, - IWorkspaceProgressResponse, - IWorkspaceAnalyticsResponse, + TCycleDistribution, + TProgressSnapshot, + TCycleEstimateDistribution, } from "@plane/types"; import { API_BASE_URL } from "@/helpers/common.helper"; import { APIService } from "@/services/api.service"; @@ -20,7 +21,7 @@ export class CycleService extends APIService { projectId: string, cycleId: string, analytic_type: string = "points" - ): Promise { + ): Promise { return this.get( `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/analytics?type=${analytic_type}` ) @@ -34,7 +35,7 @@ export class CycleService extends APIService { workspaceSlug: string, projectId: string, cycleId: string - ): Promise { + ): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/progress/`) .then((res) => res?.data) .catch((err) => { diff --git a/web/core/store/cycle.store.ts b/web/core/store/cycle.store.ts index b9a1f147648..f1b88885377 100644 --- a/web/core/store/cycle.store.ts +++ b/web/core/store/cycle.store.ts @@ -4,7 +4,14 @@ import sortBy from "lodash/sortBy"; import { action, computed, observable, makeObservable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // types -import { ICycle, CycleDateCheckData, TCyclePlotType } from "@plane/types"; +import { + ICycle, + CycleDateCheckData, + TCyclePlotType, + TProgressSnapshot, + TCycleEstimateDistribution, + TCycleDistribution, +} from "@plane/types"; // helpers import { orderCycles, shouldFilterCycle } from "@/helpers/cycle.helper"; import { getDate } from "@/helpers/date-time.helper"; @@ -55,6 +62,13 @@ export interface ICycleStore { fetchArchivedCycles: (workspaceSlug: string, projectId: string) => Promise; fetchArchivedCycleDetails: (workspaceSlug: string, projectId: string, cycleId: string) => Promise; fetchCycleDetails: (workspaceSlug: string, projectId: string, cycleId: string) => Promise; + fetchActiveCycleProgress: (workspaceSlug: string, projectId: string, cycleId: string) => Promise; + fetchActiveCycleAnalytics: ( + workspaceSlug: string, + projectId: string, + cycleId: string, + analytic_type: string + ) => Promise; // crud createCycle: (workspaceSlug: string, projectId: string, data: Partial) => Promise; updateCycleDetails: ( @@ -93,7 +107,7 @@ export class CycleStore implements ICycleStore { // observables loader: observable.ref, cycleMap: observable, - plotType: observable.ref, + plotType: observable, activeCycleIdMap: observable, fetchedMap: observable, // computed @@ -113,6 +127,8 @@ export class CycleStore implements ICycleStore { fetchActiveCycle: action, fetchArchivedCycles: action, fetchArchivedCycleDetails: action, + fetchActiveCycleProgress: action, + fetchActiveCycleAnalytics: action, fetchCycleDetails: action, createCycle: action, updateCycleDetails: action, @@ -403,6 +419,7 @@ export class CycleStore implements ICycleStore { runInAction(() => { response.forEach((cycle) => { set(this.cycleMap, [cycle.id], cycle); + cycle.status?.toLowerCase() === "current" && set(this.activeCycleIdMap, [cycle.id], true); }); set(this.fetchedMap, projectId, true); this.loader = false; @@ -457,6 +474,43 @@ export class CycleStore implements ICycleStore { return response; }); + /** + * @description fetches active cycle progress + * @param workspaceSlug + * @param projectId + * @param cycleId + * @returns + */ + fetchActiveCycleProgress = async (workspaceSlug: string, projectId: string, cycleId: string) => + await this.cycleService.workspaceActiveCyclesProgress(workspaceSlug, projectId, cycleId).then((progress) => { + runInAction(() => { + set(this.cycleMap, [cycleId], { ...this.cycleMap[cycleId], ...progress }); + }); + return progress; + }); + + /** + * @description fetches active cycle analytics + * @param workspaceSlug + * @param projectId + * @param cycleId + * @returns + */ + fetchActiveCycleAnalytics = async ( + workspaceSlug: string, + projectId: string, + cycleId: string, + analytic_type: string + ) => + await this.cycleService + .workspaceActiveCyclesAnalytics(workspaceSlug, projectId, cycleId, analytic_type) + .then((cycle) => { + runInAction(() => { + set(this.cycleMap, [cycleId, analytic_type === "points" ? "estimate_distribution" : "distribution"], cycle); + }); + return cycle; + }); + /** * @description fetches cycle details * @param workspaceSlug