Repository: makeplane/plane
Pull request #7105: [WEB-4133] chore: optimised the analytics endpoints
Author: login: NarayanBavisetti
Labels: name: ready to merge
Created: 2025-05-22T09:09:52Z | Merged: 2025-05-23T09:35:57Z

### Description
<!-- Provide a detailed description of the changes in this PR -->
optimised the analytics endpoints and separated the project and workspace level analytics.

### Type of Change
<!-- Put an 'x' in the boxes that apply -->
- [x] Code refactoring
- [ ] Performance improvements
- [ ] Documentation update

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced advanced analytics endpoints for project-level insights, statistics, and charts within workspaces.

- **Bug Fixes**
  - Simplified analytics calculations by removing filtering options for cycles, modules, and peek view, ensuring consistent workspace-level statistics and charts.

- **Refactor**
  - Updated analytics service methods and related components to change how the peek view flag is passed, improving clarity and maintainability.
  - Centralized URL construction logic for analytics API calls, enabling conditional project-specific routing.

- **Chores**
  - Cleaned up unused imports and parameters in analytics-related backend code.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/apiserver/plane/app/urls/analytic.py b/apiserver/plane/app/urls/analytic.py
index 0eebd3108bc..3e4172771b5 100644
--- a/apiserver/plane/app/urls/analytic.py
+++ b/apiserver/plane/app/urls/analytic.py
@@ -11,6 +11,9 @@
     AdvanceAnalyticsChartEndpoint,
     DefaultAnalyticsEndpoint,
     ProjectStatsEndpoint,
+    ProjectAdvanceAnalyticsEndpoint,
+    ProjectAdvanceAnalyticsStatsEndpoint,
+    ProjectAdvanceAnalyticsChartEndpoint,
 )
 
 
@@ -67,4 +70,19 @@
         AdvanceAnalyticsChartEndpoint.as_view(),
         name="advance-analytics-chart",
     ),
+    path(
+        "workspaces/<str:slug>/projects/<uuid:project_id>/advance-analytics/",
+        ProjectAdvanceAnalyticsEndpoint.as_view(),
+        name="project-advance-analytics",
+    ),
+    path(
+        "workspaces/<str:slug>/projects/<uuid:project_id>/advance-analytics-stats/",
+        ProjectAdvanceAnalyticsStatsEndpoint.as_view(),
+        name="project-advance-analytics-stats",
+    ),
+    path(
+        "workspaces/<str:slug>/projects/<uuid:project_id>/advance-analytics-charts/",
+        ProjectAdvanceAnalyticsChartEndpoint.as_view(),
+        name="project-advance-analytics-chart",
+    ),
 ]
diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py
index 2034c55487b..98dcab84fdb 100644
--- a/apiserver/plane/app/views/__init__.py
+++ b/apiserver/plane/app/views/__init__.py
@@ -205,6 +205,12 @@
     AdvanceAnalyticsChartEndpoint,
 )
 
+from .analytic.project_analytics import (
+    ProjectAdvanceAnalyticsEndpoint,
+    ProjectAdvanceAnalyticsStatsEndpoint,
+    ProjectAdvanceAnalyticsChartEndpoint,
+)
+
 from .notification.base import (
     NotificationViewSet,
     UnreadNotificationEndpoint,
diff --git a/apiserver/plane/app/views/analytic/advance.py b/apiserver/plane/app/views/analytic/advance.py
index c55f5566b13..a79ab98c58f 100644
--- a/apiserver/plane/app/views/analytic/advance.py
+++ b/apiserver/plane/app/views/analytic/advance.py
@@ -5,7 +5,6 @@
 from django.http import HttpRequest
 from django.db.models.functions import TruncMonth
 from django.utils import timezone
-from datetime import timedelta
 from plane.app.views.base import BaseAPIView
 from plane.app.permissions import ROLE, allow_permission
 from plane.db.models import (
@@ -17,12 +16,7 @@
     IssueView,
     ProjectPage,
     Workspace,
-    CycleIssue,
-    ModuleIssue,
 )
-from django.db import models
-from django.db.models import F, Case, When, Value
-from django.db.models.functions import Concat
 from plane.utils.build_chart import build_analytics_chart
 from plane.utils.date_utils import (
     get_analytics_filters,
@@ -118,25 +112,8 @@ def get_overview_data(self) -> Dict[str, Dict[str, int]]:
             ),
         }
 
-    def get_work_items_stats(
-        self, cycle_id=None, module_id=None
-    ) -> Dict[str, Dict[str, int]]:
-        """
-        Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
-        """
-        base_queryset = None
-        if cycle_id is not None:
-            cycle_issues = CycleIssue.objects.filter(
-                **self.filters["base_filters"], cycle_id=cycle_id
-            ).values_list("issue_id", flat=True)
-            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
-        elif module_id is not None:
-            module_issues = ModuleIssue.objects.filter(
-                **self.filters["base_filters"], module_id=module_id
-            ).values_list("issue_id", flat=True)
-            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
-        else:
-            base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
+    def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
+        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
 
         return {
             "total_work_items": self.get_filtered_counts(base_queryset),
@@ -165,11 +142,8 @@ def get(self, request: HttpRequest, slug: str) -> Response:
                 status=status.HTTP_200_OK,
             )
         elif tab == "work-items":
-            # Optionally accept cycle_id or module_id as query params
-            cycle_id = request.GET.get("cycle_id", None)
-            module_id = request.GET.get("module_id", None)
             return Response(
-                self.get_work_items_stats(cycle_id=cycle_id, module_id=module_id),
+                self.get_work_items_stats(),
                 status=status.HTTP_200_OK,
             )
         return Response({"message": "Invalid tab"}, status=status.HTTP_400_BAD_REQUEST)
@@ -188,8 +162,7 @@ def get_project_issues_stats(self) -> QuerySet:
             )
 
         return (
-            base_queryset.values("project_id", "project__name")
-            .annotate(
+            base_queryset.values("project_id", "project__name").annotate(
                 cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
                 completed_work_items=Count("id", filter=Q(state__group="completed")),
                 backlog_work_items=Count("id", filter=Q(state__group="backlog")),
@@ -199,84 +172,19 @@ def get_project_issues_stats(self) -> QuerySet:
             .order_by("project_id")
         )
 
-    def get_work_items_stats(
-        self, cycle_id=None, module_id=None, peek_view=False
-    ) -> Dict[str, Dict[str, int]]:
-        base_queryset = None
-        if cycle_id is not None:
-            cycle_issues = CycleIssue.objects.filter(
-                **self.filters["base_filters"], cycle_id=cycle_id
-            ).values_list("issue_id", flat=True)
-            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
-        elif module_id is not None:
-            module_issues = ModuleIssue.objects.filter(
-                **self.filters["base_filters"], module_id=module_id
-            ).values_list("issue_id", flat=True)
-            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
-        elif peek_view:
-            base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
-        else:
-            base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
-            return (
-                base_queryset.values("project_id", "project__name")
-                .annotate(
-                    cancelled_work_items=Count(
-                        "id", filter=Q(state__group="cancelled")
-                    ),
-                    completed_work_items=Count(
-                        "id", filter=Q(state__group="completed")
-                    ),
-                    backlog_work_items=Count("id", filter=Q(state__group="backlog")),
-                    un_started_work_items=Count(
-                        "id", filter=Q(state__group="unstarted")
-                    ),
-                    started_work_items=Count("id", filter=Q(state__group="started")),
-                )
-                .order_by("project_id")
-            )
-
+    def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
+        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
         return (
-            base_queryset.annotate(display_name=F("assignees__display_name"))
-            .annotate(assignee_id=F("assignees__id"))
-            .annotate(avatar=F("assignees__avatar"))
+            base_queryset
+            .values("project_id", "project__name")
             .annotate(
-                avatar_url=Case(
-                    # If `avatar_asset` exists, use it to generate the asset URL
-                    When(
-                        assignees__avatar_asset__isnull=False,
-                        then=Concat(
-                            Value("/api/assets/v2/static/"),
-                            "assignees__avatar_asset",  # Assuming avatar_asset has an id or relevant field
-                            Value("/"),
-                        ),
-                    ),
-                    # If `avatar_asset` is None, fall back to using `avatar` field directly
-                    When(
-                        assignees__avatar_asset__isnull=True, then="assignees__avatar"
-                    ),
-                    default=Value(None),
-                    output_field=models.CharField(),
-                )
-            )
-            .values("display_name", "assignee_id", "avatar_url")
-            .annotate(
-                cancelled_work_items=Count(
-                    "id", filter=Q(state__group="cancelled"), distinct=True
-                ),
-                completed_work_items=Count(
-                    "id", filter=Q(state__group="completed"), distinct=True
-                ),
-                backlog_work_items=Count(
-                    "id", filter=Q(state__group="backlog"), distinct=True
-                ),
-                un_started_work_items=Count(
-                    "id", filter=Q(state__group="unstarted"), distinct=True
-                ),
-                started_work_items=Count(
-                    "id", filter=Q(state__group="started"), distinct=True
-                ),
+                cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
+                completed_work_items=Count("id", filter=Q(state__group="completed")),
+                backlog_work_items=Count("id", filter=Q(state__group="backlog")),
+                un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
+                started_work_items=Count("id", filter=Q(state__group="started")),
             )
-            .order_by("display_name")
+            .order_by("project_id")
         )
 
     @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
@@ -285,14 +193,8 @@ def get(self, request: HttpRequest, slug: str) -> Response:
         type = request.GET.get("type", "work-items")
 
         if type == "work-items":
-            # Optionally accept cycle_id or module_id as query params
-            cycle_id = request.GET.get("cycle_id", None)
-            module_id = request.GET.get("module_id", None)
-            peek_view = request.GET.get("peek_view", False)
             return Response(
-                self.get_work_items_stats(
-                    cycle_id=cycle_id, module_id=module_id, peek_view=peek_view
-                ),
+                self.get_work_items_stats(),
                 status=status.HTTP_200_OK,
             )
 
@@ -352,9 +254,7 @@ def project_chart(self) -> List[Dict[str, Any]]:
             for key, value in data.items()
         ]
 
-    def work_item_completion_chart(
-        self, cycle_id=None, module_id=None, peek_view=False
-    ) -> Dict[str, Any]:
+    def work_item_completion_chart(self) -> Dict[str, Any]:
         # Get the base queryset
         queryset = (
             Issue.issue_objects.filter(**self.filters["base_filters"])
@@ -364,143 +264,62 @@ def work_item_completion_chart(
             )
         )
 
-        if cycle_id is not None and peek_view:
-            cycle_issues = CycleIssue.objects.filter(
-                **self.filters["base_filters"], cycle_id=cycle_id
-            ).values_list("issue_id", flat=True)
-            cycle = Cycle.objects.filter(id=cycle_id).first()
-            if cycle and cycle.start_date:
-                start_date = cycle.start_date.date()
-                end_date = cycle.end_date.date()
-            else:
-                return {"data": [], "schema": {}}
-            queryset = cycle_issues
-        elif module_id is not None and peek_view:
-            module_issues = ModuleIssue.objects.filter(
-                **self.filters["base_filters"], module_id=module_id
-            ).values_list("issue_id", flat=True)
-            module = Module.objects.filter(id=module_id).first()
-            if module and module.start_date:
-                start_date = module.start_date
-                end_date = module.target_date
-            else:
-                return {"data": [], "schema": {}}
-            queryset = module_issues
-        elif peek_view:
-            project_ids_str = self.request.GET.get("project_ids")
-            if project_ids_str:
-                project_id_list = [
-                    pid.strip() for pid in project_ids_str.split(",") if pid.strip()
-                ]
-            else:
-                project_id_list = []
-                return {"data": [], "schema": {}}
-            project_id = project_id_list[0]
-            project = Project.objects.filter(id=project_id).first()
-            if project.created_at:
-                start_date = project.created_at.date().replace(day=1)
-            else:
-                return {"data": [], "schema": {}}
-        else:
-            workspace = Workspace.objects.get(slug=self._workspace_slug)
-            start_date = workspace.created_at.date().replace(day=1)
-
-        if cycle_id or module_id:
-            # Get daily stats with optimized query
-            daily_stats = (
-                queryset.values("created_at__date")
-                .annotate(
-                    created_count=Count("id"),
-                    completed_count=Count(
-                        "id", filter=Q(issue__state__group="completed")
-                    ),
-                )
-                .order_by("created_at__date")
-            )
-
-            # Create a dictionary of existing stats with summed counts
-            stats_dict = {
-                stat["created_at__date"].strftime("%Y-%m-%d"): {
-                    "created_count": stat["created_count"],
-                    "completed_count": stat["completed_count"],
-                }
-                for stat in daily_stats
-            }
+        workspace = Workspace.objects.get(slug=self._workspace_slug)
+        start_date = workspace.created_at.date().replace(day=1)
 
-            # Generate data for all days in the range
-            data = []
-            current_date = start_date
-            while current_date <= end_date:
-                date_str = current_date.strftime("%Y-%m-%d")
-                stats = stats_dict.get(
-                    date_str, {"created_count": 0, "completed_count": 0}
-                )
-                data.append(
-                    {
-                        "key": date_str,
-                        "name": date_str,
-                        "count": stats["created_count"] + stats["completed_count"],
-                        "completed_issues": stats["completed_count"],
-                        "created_issues": stats["created_count"],
-                    }
-                )
-                current_date += timedelta(days=1)
-        else:
-            # Apply date range filter if available
-            if self.filters["chart_period_range"]:
-                start_date, end_date = self.filters["chart_period_range"]
-                queryset = queryset.filter(
-                    created_at__date__gte=start_date, created_at__date__lte=end_date
-                )
+        # Apply date range filter if available
+        if self.filters["chart_period_range"]:
+            start_date, end_date = self.filters["chart_period_range"]
+            queryset = queryset.filter(
+                created_at__date__gte=start_date, created_at__date__lte=end_date
+            )
 
-            # Annotate by month and count
-            monthly_stats = (
-                queryset.annotate(month=TruncMonth("created_at"))
-                .values("month")
-                .annotate(
-                    created_count=Count("id"),
-                    completed_count=Count("id", filter=Q(state__group="completed")),
-                )
-                .order_by("month")
+        # Annotate by month and count
+        monthly_stats = (
+            queryset.annotate(month=TruncMonth("created_at"))
+            .values("month")
+            .annotate(
+                created_count=Count("id"),
+                completed_count=Count("id", filter=Q(state__group="completed")),
             )
+            .order_by("month")
+        )
 
-            # Create dictionary of month -> counts
-            stats_dict = {
-                stat["month"].strftime("%Y-%m-%d"): {
-                    "created_count": stat["created_count"],
-                    "completed_count": stat["completed_count"],
-                }
-                for stat in monthly_stats
+        # Create dictionary of month -> counts
+        stats_dict = {
+            stat["month"].strftime("%Y-%m-%d"): {
+                "created_count": stat["created_count"],
+                "completed_count": stat["completed_count"],
             }
+            for stat in monthly_stats
+        }
 
-            # Generate monthly data (ensure months with 0 count are included)
-            data = []
-            # include the current date at the end
-            end_date = timezone.now().date()
-            last_month = end_date.replace(day=1)
-            current_month = start_date
-
-            while current_month <= last_month:
-                date_str = current_month.strftime("%Y-%m-%d")
-                stats = stats_dict.get(
-                    date_str, {"created_count": 0, "completed_count": 0}
-                )
-                data.append(
-                    {
-                        "key": date_str,
-                        "name": date_str,
-                        "count": stats["created_count"],
-                        "completed_issues": stats["completed_count"],
-                        "created_issues": stats["created_count"],
-                    }
+        # Generate monthly data (ensure months with 0 count are included)
+        data = []
+        # include the current date at the end
+        end_date = timezone.now().date()
+        last_month = end_date.replace(day=1)
+        current_month = start_date
+
+        while current_month <= last_month:
+            date_str = current_month.strftime("%Y-%m-%d")
+            stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0})
+            data.append(
+                {
+                    "key": date_str,
+                    "name": date_str,
+                    "count": stats["created_count"],
+                    "completed_issues": stats["completed_count"],
+                    "created_issues": stats["created_count"],
+                }
+            )
+            # Move to next month
+            if current_month.month == 12:
+                current_month = current_month.replace(
+                    year=current_month.year + 1, month=1
                 )
-                # Move to next month
-                if current_month.month == 12:
-                    current_month = current_month.replace(
-                        year=current_month.year + 1, month=1
-                    )
-                else:
-                    current_month = current_month.replace(month=current_month.month + 1)
+            else:
+                current_month = current_month.replace(month=current_month.month + 1)
 
         schema = {
             "completed_issues": "completed_issues",
@@ -515,8 +334,6 @@ def get(self, request: HttpRequest, slug: str) -> Response:
         type = request.GET.get("type", "projects")
         group_by = request.GET.get("group_by", None)
         x_axis = request.GET.get("x_axis", "PRIORITY")
-        cycle_id = request.GET.get("cycle_id", None)
-        module_id = request.GET.get("module_id", None)
 
         if type == "projects":
             return Response(self.project_chart(), status=status.HTTP_200_OK)
@@ -530,19 +347,6 @@ def get(self, request: HttpRequest, slug: str) -> Response:
                 )
             )
 
-            # Apply cycle/module filters if present
-            if cycle_id is not None:
-                cycle_issues = CycleIssue.objects.filter(
-                    **self.filters["base_filters"], cycle_id=cycle_id
-                ).values_list("issue_id", flat=True)
-                queryset = queryset.filter(id__in=cycle_issues)
-
-            elif module_id is not None:
-                module_issues = ModuleIssue.objects.filter(
-                    **self.filters["base_filters"], module_id=module_id
-                ).values_list("issue_id", flat=True)
-                queryset = queryset.filter(id__in=module_issues)
-
             # Apply date range filter if available
             if self.filters["chart_period_range"]:
                 start_date, end_date = self.filters["chart_period_range"]
@@ -556,14 +360,8 @@ def get(self, request: HttpRequest, slug: str) -> Response:
             )
 
         elif type == "work-items":
-            # Optionally accept cycle_id or module_id as query params
-            cycle_id = request.GET.get("cycle_id", None)
-            module_id = request.GET.get("module_id", None)
-            peek_view = request.GET.get("peek_view", False)
             return Response(
-                self.work_item_completion_chart(
-                    cycle_id=cycle_id, module_id=module_id, peek_view=peek_view
-                ),
+                self.work_item_completion_chart(),
                 status=status.HTTP_200_OK,
             )
 
diff --git a/apiserver/plane/app/views/analytic/project_analytics.py b/apiserver/plane/app/views/analytic/project_analytics.py
new file mode 100644
index 00000000000..655f8e98984
--- /dev/null
+++ b/apiserver/plane/app/views/analytic/project_analytics.py
@@ -0,0 +1,421 @@
+from rest_framework.response import Response
+from rest_framework import status
+from typing import Dict, Any
+from django.db.models import QuerySet, Q, Count
+from django.http import HttpRequest
+from django.db.models.functions import TruncMonth
+from django.utils import timezone
+from datetime import timedelta
+from plane.app.views.base import BaseAPIView
+from plane.app.permissions import ROLE, allow_permission
+from plane.db.models import (
+    Project,
+    Issue,
+    Cycle,
+    Module,
+    CycleIssue,
+    ModuleIssue,
+)
+from django.db import models
+from django.db.models import F, Case, When, Value
+from django.db.models.functions import Concat
+from plane.utils.build_chart import build_analytics_chart
+from plane.utils.date_utils import (
+    get_analytics_filters,
+)
+
+
+class ProjectAdvanceAnalyticsBaseView(BaseAPIView):
+    def initialize_workspace(self, slug: str, type: str) -> None:
+        self._workspace_slug = slug
+        self.filters = get_analytics_filters(
+            slug=slug,
+            type=type,
+            user=self.request.user,
+            date_filter=self.request.GET.get("date_filter", None),
+            project_ids=self.request.GET.get("project_ids", None),
+        )
+
+
+class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
+        def get_filtered_count() -> int:
+            if self.filters["analytics_date_range"]:
+                return queryset.filter(
+                    created_at__gte=self.filters["analytics_date_range"]["current"][
+                        "gte"
+                    ],
+                    created_at__lte=self.filters["analytics_date_range"]["current"][
+                        "lte"
+                    ],
+                ).count()
+            return queryset.count()
+
+        return {
+            "count": get_filtered_count(),
+        }
+
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        """
+        Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
+        """
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+
+        return {
+            "total_work_items": self.get_filtered_counts(base_queryset),
+            "started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="started")
+            ),
+            "backlog_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="backlog")
+            ),
+            "un_started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="unstarted")
+            ),
+            "completed_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="completed")
+            ),
+        }
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="analytics")
+
+        # Optionally accept cycle_id or module_id as query params
+        cycle_id = request.GET.get("cycle_id", None)
+        module_id = request.GET.get("module_id", None)
+        return Response(
+            self.get_work_items_stats(
+                cycle_id=cycle_id, module_id=module_id, project_id=project_id
+            ),
+            status=status.HTTP_200_OK,
+        )
+
+
+class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_project_issues_stats(self) -> QuerySet:
+        # Get the base queryset with workspace and project filters
+        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
+
+        # Apply date range filter if available
+        if self.filters["chart_period_range"]:
+            start_date, end_date = self.filters["chart_period_range"]
+            base_queryset = base_queryset.filter(
+                created_at__date__gte=start_date, created_at__date__lte=end_date
+            )
+
+        return (
+            base_queryset.values("project_id", "project__name")
+            .annotate(
+                cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
+                completed_work_items=Count("id", filter=Q(state__group="completed")),
+                backlog_work_items=Count("id", filter=Q(state__group="backlog")),
+                un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
+                started_work_items=Count("id", filter=Q(state__group="started")),
+            )
+            .order_by("project_id")
+        )
+
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+        return (
+            base_queryset.annotate(display_name=F("assignees__display_name"))
+            .annotate(assignee_id=F("assignees__id"))
+            .annotate(avatar=F("assignees__avatar"))
+            .annotate(
+                avatar_url=Case(
+                    # If `avatar_asset` exists, use it to generate the asset URL
+                    When(
+                        assignees__avatar_asset__isnull=False,
+                        then=Concat(
+                            Value("/api/assets/v2/static/"),
+                            "assignees__avatar_asset",  # Assuming avatar_asset has an id or relevant field
+                            Value("/"),
+                        ),
+                    ),
+                    # If `avatar_asset` is None, fall back to using `avatar` field directly
+                    When(
+                        assignees__avatar_asset__isnull=True, then="assignees__avatar"
+                    ),
+                    default=Value(None),
+                    output_field=models.CharField(),
+                )
+            )
+            .values("display_name", "assignee_id", "avatar_url")
+            .annotate(
+                cancelled_work_items=Count(
+                    "id", filter=Q(state__group="cancelled"), distinct=True
+                ),
+                completed_work_items=Count(
+                    "id", filter=Q(state__group="completed"), distinct=True
+                ),
+                backlog_work_items=Count(
+                    "id", filter=Q(state__group="backlog"), distinct=True
+                ),
+                un_started_work_items=Count(
+                    "id", filter=Q(state__group="unstarted"), distinct=True
+                ),
+                started_work_items=Count(
+                    "id", filter=Q(state__group="started"), distinct=True
+                ),
+            )
+            .order_by("display_name")
+        )
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="chart")
+        type = request.GET.get("type", "work-items")
+
+        if type == "work-items":
+            # Optionally accept cycle_id or module_id as query params
+            cycle_id = request.GET.get("cycle_id", None)
+            module_id = request.GET.get("module_id", None)
+            return Response(
+                self.get_work_items_stats(
+                    project_id=project_id, cycle_id=cycle_id, module_id=module_id
+                ),
+                status=status.HTTP_200_OK,
+            )
+
+        return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
+
+
+class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def work_item_completion_chart(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Any]:
+        # Get the base queryset
+        queryset = (
+            Issue.issue_objects.filter(**self.filters["base_filters"])
+            .filter(project_id=project_id)
+            .select_related("workspace", "state", "parent")
+            .prefetch_related(
+                "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
+            )
+        )
+
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            cycle = Cycle.objects.filter(id=cycle_id).first()
+            if cycle and cycle.start_date:
+                start_date = cycle.start_date.date()
+                end_date = cycle.end_date.date()
+            else:
+                return {"data": [], "schema": {}}
+            queryset = cycle_issues
+
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            module = Module.objects.filter(id=module_id).first()
+            if module and module.start_date:
+                start_date = module.start_date
+                end_date = module.target_date
+            else:
+                return {"data": [], "schema": {}}
+            queryset = module_issues
+
+        else:
+            project = Project.objects.filter(id=project_id).first()
+            if project.created_at:
+                start_date = project.created_at.date().replace(day=1)
+            else:
+                return {"data": [], "schema": {}}
+
+        if cycle_id or module_id:
+            # Get daily stats with optimized query
+            daily_stats = (
+                queryset.values("created_at__date")
+                .annotate(
+                    created_count=Count("id"),
+                    completed_count=Count(
+                        "id", filter=Q(issue__state__group="completed")
+                    ),
+                )
+                .order_by("created_at__date")
+            )
+
+            # Create a dictionary of existing stats with summed counts
+            stats_dict = {
+                stat["created_at__date"].strftime("%Y-%m-%d"): {
+                    "created_count": stat["created_count"],
+                    "completed_count": stat["completed_count"],
+                }
+                for stat in daily_stats
+            }
+
+            # Generate data for all days in the range
+            data = []
+            current_date = start_date
+            while current_date <= end_date:
+                date_str = current_date.strftime("%Y-%m-%d")
+                stats = stats_dict.get(
+                    date_str, {"created_count": 0, "completed_count": 0}
+                )
+                data.append(
+                    {
+                        "key": date_str,
+                        "name": date_str,
+                        "count": stats["created_count"] + stats["completed_count"],
+                        "completed_issues": stats["completed_count"],
+                        "created_issues": stats["created_count"],
+                    }
+                )
+                current_date += timedelta(days=1)
+        else:
+            # Apply date range filter if available
+            if self.filters["chart_period_range"]:
+                start_date, end_date = self.filters["chart_period_range"]
+                queryset = queryset.filter(
+                    created_at__date__gte=start_date, created_at__date__lte=end_date
+                )
+
+            # Annotate by month and count
+            monthly_stats = (
+                queryset.annotate(month=TruncMonth("created_at"))
+                .values("month")
+                .annotate(
+                    created_count=Count("id"),
+                    completed_count=Count("id", filter=Q(state__group="completed")),
+                )
+                .order_by("month")
+            )
+
+            # Create dictionary of month -> counts
+            stats_dict = {
+                stat["month"].strftime("%Y-%m-%d"): {
+                    "created_count": stat["created_count"],
+                    "completed_count": stat["completed_count"],
+                }
+                for stat in monthly_stats
+            }
+
+            # Generate monthly data (ensure months with 0 count are included)
+            data = []
+            # include the current date at the end
+            end_date = timezone.now().date()
+            last_month = end_date.replace(day=1)
+            current_month = start_date
+
+            while current_month <= last_month:
+                date_str = current_month.strftime("%Y-%m-%d")
+                stats = stats_dict.get(
+                    date_str, {"created_count": 0, "completed_count": 0}
+                )
+                data.append(
+                    {
+                        "key": date_str,
+                        "name": date_str,
+                        "count": stats["created_count"],
+                        "completed_issues": stats["completed_count"],
+                        "created_issues": stats["created_count"],
+                    }
+                )
+                # Move to next month
+                if current_month.month == 12:
+                    current_month = current_month.replace(
+                        year=current_month.year + 1, month=1
+                    )
+                else:
+                    current_month = current_month.replace(month=current_month.month + 1)
+
+        schema = {
+            "completed_issues": "completed_issues",
+            "created_issues": "created_issues",
+        }
+
+        return {"data": data, "schema": schema}
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="chart")
+        type = request.GET.get("type", "projects")
+        group_by = request.GET.get("group_by", None)
+        x_axis = request.GET.get("x_axis", "PRIORITY")
+        cycle_id = request.GET.get("cycle_id", None)
+        module_id = request.GET.get("module_id", None)
+
+        if type == "custom-work-items":
+            queryset = (
+                Issue.issue_objects.filter(**self.filters["base_filters"])
+                .filter(project_id=project_id)
+                .select_related("workspace", "state", "parent")
+                .prefetch_related(
+                    "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
+                )
+            )
+
+            # Apply cycle/module filters if present
+            if cycle_id is not None:
+                cycle_issues = CycleIssue.objects.filter(
+                    **self.filters["base_filters"], cycle_id=cycle_id
+                ).values_list("issue_id", flat=True)
+                queryset = queryset.filter(id__in=cycle_issues)
+
+            elif module_id is not None:
+                module_issues = ModuleIssue.objects.filter(
+                    **self.filters["base_filters"], module_id=module_id
+                ).values_list("issue_id", flat=True)
+                queryset = queryset.filter(id__in=module_issues)
+
+            # Apply date range filter if available
+            if self.filters["chart_period_range"]:
+                start_date, end_date = self.filters["chart_period_range"]
+                queryset = queryset.filter(
+                    created_at__date__gte=start_date, created_at__date__lte=end_date
+                )
+
+            return Response(
+                build_analytics_chart(queryset, x_axis, group_by),
+                status=status.HTTP_200_OK,
+            )
+
+        elif type == "work-items":
+            # Optionally accept cycle_id or module_id as query params
+            cycle_id = request.GET.get("cycle_id", None)
+            module_id = request.GET.get("module_id", None)
+
+            return Response(
+                self.work_item_completion_chart(
+                    project_id=project_id, cycle_id=cycle_id, module_id=module_id
+                ),
+                status=status.HTTP_200_OK,
+            )
+
+        return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
diff --git a/web/core/components/analytics-v2/overview/project-insights.tsx b/web/core/components/analytics-v2/overview/project-insights.tsx
index 83054a885f8..9c8f829a14d 100644
--- a/web/core/components/analytics-v2/overview/project-insights.tsx
+++ b/web/core/components/analytics-v2/overview/project-insights.tsx
@@ -39,8 +39,9 @@ const ProjectInsights = observer(() => {
         ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
         ...(selectedCycle ? { cycle_id: selectedCycle } : {}),
         ...(selectedModule ? { module_id: selectedModule } : {}),
-        ...(isPeekView ? { peek_view: true } : {}),
-      })
+      },
+        isPeekView
+      )
   );
 
   return (
diff --git a/web/core/components/analytics-v2/total-insights.tsx b/web/core/components/analytics-v2/total-insights.tsx
index 8a3ffe8b75d..5fc8b2239eb 100644
--- a/web/core/components/analytics-v2/total-insights.tsx
+++ b/web/core/components/analytics-v2/total-insights.tsx
@@ -26,13 +26,17 @@ const TotalInsights: React.FC<{ analyticsType: TAnalyticsTabsV2Base; peekView?:
     const { data: totalInsightsData, isLoading } = useSWR(
       `total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
       () =>
-        analyticsV2Service.getAdvanceAnalytics<IAnalyticsResponseV2>(workspaceSlug, analyticsType, {
-          // date_filter: selectedDuration,
-          ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
-          ...(selectedCycle ? { cycle_id: selectedCycle } : {}),
-          ...(selectedModule ? { module_id: selectedModule } : {}),
-          ...(isPeekView ? { peek_view: true } : {}),
-        })
+        analyticsV2Service.getAdvanceAnalytics<IAnalyticsResponseV2>(
+          workspaceSlug,
+          analyticsType,
+          {
+            // date_filter: selectedDuration,
+            ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
+            ...(selectedCycle ? { cycle_id: selectedCycle } : {}),
+            ...(selectedModule ? { module_id: selectedModule } : {}),
+          },
+          isPeekView
+        )
     );
     return (
       <div
diff --git a/web/core/components/analytics-v2/work-items/created-vs-resolved.tsx b/web/core/components/analytics-v2/work-items/created-vs-resolved.tsx
index 873ac2ed776..46f3faf54e4 100644
--- a/web/core/components/analytics-v2/work-items/created-vs-resolved.tsx
+++ b/web/core/components/analytics-v2/work-items/created-vs-resolved.tsx
@@ -33,8 +33,9 @@ const CreatedVsResolved = observer(() => {
         ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
         ...(selectedCycle ? { cycle_id: selectedCycle } : {}),
         ...(selectedModule ? { module_id: selectedModule } : {}),
-        ...(isPeekView ? { peek_view: true } : {}),
-      })
+      },
+        isPeekView
+      )
   );
   const parsedData: TChartData<string, string>[] = useMemo(() => {
     if (!createdVsResolvedData?.data) return [];
diff --git a/web/core/components/analytics-v2/work-items/priority-chart.tsx b/web/core/components/analytics-v2/work-items/priority-chart.tsx
index 664824851ed..c5b31bb168f 100644
--- a/web/core/components/analytics-v2/work-items/priority-chart.tsx
+++ b/web/core/components/analytics-v2/work-items/priority-chart.tsx
@@ -57,14 +57,18 @@ const PriorityChart = observer((props: Props) => {
     `customized-insights-chart-${workspaceSlug}-${selectedDuration}-
     ${selectedProjects}-${selectedCycle}-${selectedModule}-${props.x_axis}-${props.y_axis}-${props.group_by}-${isPeekView}`,
     () =>
-      analyticsV2Service.getAdvanceAnalyticsCharts<TChart>(workspaceSlug, "custom-work-items", {
-        // date_filter: selectedDuration,
-        ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
-        ...(selectedCycle ? { cycle_id: selectedCycle } : {}),
-        ...(selectedModule ? { module_id: selectedModule } : {}),
-        ...(isPeekView ? { peek_view: true } : {}),
-        ...props,
-      })
+      analyticsV2Service.getAdvanceAnalyticsCharts<TChart>(
+        workspaceSlug,
+        "custom-work-items",
+        {
+          // date_filter: selectedDuration,
+          ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
+          ...(selectedCycle ? { cycle_id: selectedCycle } : {}),
+          ...(selectedModule ? { module_id: selectedModule } : {}),
+          ...props,
+        },
+        isPeekView
+      )
   );
   const parsedData = useMemo(
     () =>
diff --git a/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx b/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx
index e428244925d..1fd83432965 100644
--- a/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx
+++ b/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx
@@ -31,13 +31,17 @@ const WorkItemsInsightTable = observer(() => {
   const { data: workItemsData, isLoading } = useSWR(
     `insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
     () =>
-      analyticsV2Service.getAdvanceAnalyticsStats<WorkItemInsightColumns[]>(workspaceSlug, "work-items", {
-        // date_filter: selectedDuration,
-        ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
-        ...(selectedCycle ? { cycle_id: selectedCycle } : {}),
-        ...(selectedModule ? { module_id: selectedModule } : {}),
-        ...(isPeekView ? { peek_view: true } : {}),
-      })
+      analyticsV2Service.getAdvanceAnalyticsStats<WorkItemInsightColumns[]>(
+        workspaceSlug,
+        "work-items",
+        {
+          // date_filter: selectedDuration,
+          ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
+          ...(selectedCycle ? { cycle_id: selectedCycle } : {}),
+          ...(selectedModule ? { module_id: selectedModule } : {}),
+        },
+        isPeekView
+      )
   );
   // derived values
   const columnsLabels = useMemo(
diff --git a/web/core/services/analytics-v2.service.ts b/web/core/services/analytics-v2.service.ts
index 87257cbc6b0..05e1b78b728 100644
--- a/web/core/services/analytics-v2.service.ts
+++ b/web/core/services/analytics-v2.service.ts
@@ -10,14 +10,18 @@ export class AnalyticsV2Service extends APIService {
   async getAdvanceAnalytics<T extends IAnalyticsResponseV2>(
     workspaceSlug: string,
     tab: TAnalyticsTabsV2Base,
-    params?: Record<string, any>
+    params?: Record<string, any>,
+    isPeekView?: boolean
   ): Promise<T> {
-    return this.get(`/api/workspaces/${workspaceSlug}/advance-analytics/`, {
-      params: {
-        tab,
-        ...params,
-      },
-    })
+    return this.get(
+      this.processUrl<TAnalyticsTabsV2Base>("advance-analytics", workspaceSlug, tab, params, isPeekView),
+      {
+        params: {
+          tab,
+          ...params,
+        },
+      }
+    )
       .then((res) => res?.data)
       .catch((err) => {
         throw err?.response?.data;
@@ -27,9 +31,17 @@ export class AnalyticsV2Service extends APIService {
   async getAdvanceAnalyticsStats<T>(
     workspaceSlug: string,
     tab: Exclude<TAnalyticsTabsV2Base, "overview">,
-    params?: Record<string, any>
+    params?: Record<string, any>,
+    isPeekView?: boolean
   ): Promise<T> {
-    return this.get(`/api/workspaces/${workspaceSlug}/advance-analytics-stats/`, {
+    const processedUrl = this.processUrl<Exclude<TAnalyticsTabsV2Base, "overview">>(
+      "advance-analytics-stats",
+      workspaceSlug,
+      tab,
+      params,
+      isPeekView
+    );
+    return this.get(processedUrl, {
       params: {
         type: tab,
         ...params,
@@ -44,9 +56,17 @@ export class AnalyticsV2Service extends APIService {
   async getAdvanceAnalyticsCharts<T>(
     workspaceSlug: string,
     tab: TAnalyticsGraphsV2Base,
-    params?: Record<string, any>
+    params?: Record<string, any>,
+    isPeekView?: boolean
   ): Promise<T> {
-    return this.get(`/api/workspaces/${workspaceSlug}/advance-analytics-charts/`, {
+    const processedUrl = this.processUrl<TAnalyticsGraphsV2Base>(
+      "advance-analytics-charts",
+      workspaceSlug,
+      tab,
+      params,
+      isPeekView
+    );
+    return this.get(processedUrl, {
       params: {
         type: tab,
         ...params,
@@ -57,4 +77,19 @@ export class AnalyticsV2Service extends APIService {
         throw err?.response?.data;
       });
   }
+
+  processUrl<T extends string>(
+    endpoint: string,
+    workspaceSlug: string,
+    tab: T,
+    params?: Record<string, any>,
+    isPeekView?: boolean
+  ) {
+    let processedUrl = `/api/workspaces/${workspaceSlug}`;
+    if (isPeekView && tab === "work-items") {
+      const projectId = params?.project_ids.split(",")[0];
+      processedUrl += `/projects/${projectId}`;
+    }
+    return `${processedUrl}/${endpoint}`;
+  }
 }


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 5**

<details>
<summary>🔭 Outside diff range comments (1)</summary><blockquote>

<details>
<summary>apiserver/plane/app/views/analytic/advance.py (1)</summary><blockquote>

`123-129`: _🛠️ Refactor suggestion_

_⚠️ Potential issue_

**Inconsistent key naming may break consumers**

`"un_started_work_items"` mixes snake-case with an underscore after *un*, whereas the associated state group is `"unstarted"`.  Everywhere else in the codebase we seem to use `"unstarted"` without the extra underscore (e.g. `state__group="unstarted"`).

If any front-end charts, serializers, or tests expect `"unstarted_work_items"`, this mismatch will silently return `undefined/None`.

```diff
-            "un_started_work_items": self.get_filtered_counts(
-                base_queryset.filter(state__group="unstarted")
-            ),
+            "unstarted_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="unstarted")
+            ),
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>apiserver/plane/app/views/analytic/advance.py (1)</summary><blockquote>

`313-318`: **`count` only reflects created issues, not the total**

Within the monthly loop you set  

```python
"count": stats["created_count"],
```

but in the daily (project-level) implementation the same field is `created + completed`.  Unless the UI consciously expects different semantics, this is easy to mis-interpret.

```diff
-                    "count": stats["created_count"],
+                    "count": stats["created_count"] + stats["completed_count"],
```

</blockquote></details>
<details>
<summary>apiserver/plane/app/views/analytic/project_analytics.py (1)</summary><blockquote>

`321-336`: **Monthly loop may skip months before the project start**

`current_month = start_date` (project start date) but the earlier stats query may have been sliced by `chart_period_range` overriding `start_date`.  
If the date filter starts *after* the project’s creation, the loop will emit empty months unnecessarily.

Consider aligning `current_month` with the effective `start_date` after the optional range filter, or simply iterate over the keys present in `stats_dict`.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 0a8cc24da505fd519fcc3c9d6b5e15bc7ce21b29 and c69bd614abe4ea5be149c85bfe0d2e6a0bf8986d.

</details>

<details>
<summary>📒 Files selected for processing (10)</summary>

* `apiserver/plane/app/urls/analytic.py` (2 hunks)
* `apiserver/plane/app/views/__init__.py` (1 hunks)
* `apiserver/plane/app/views/analytic/advance.py` (7 hunks)
* `apiserver/plane/app/views/analytic/project_analytics.py` (1 hunks)
* `web/core/components/analytics-v2/overview/project-insights.tsx` (1 hunks)
* `web/core/components/analytics-v2/total-insights.tsx` (1 hunks)
* `web/core/components/analytics-v2/work-items/created-vs-resolved.tsx` (1 hunks)
* `web/core/components/analytics-v2/work-items/priority-chart.tsx` (1 hunks)
* `web/core/components/analytics-v2/work-items/workitems-insight-table.tsx` (1 hunks)
* `web/core/services/analytics-v2.service.ts` (4 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (2)</summary>

<details>
<summary>web/core/services/analytics-v2.service.ts (2)</summary><blockquote>

<details>
<summary>packages/types/src/analytics-v2.d.ts (2)</summary>

* `TAnalyticsTabsV2Base` (4-4)
* `TAnalyticsGraphsV2Base` (5-5)

</details>
<details>
<summary>web/core/store/router.store.ts (2)</summary>

* `workspaceSlug` (69-71)
* `projectId` (85-87)

</details>

</blockquote></details>
<details>
<summary>apiserver/plane/app/views/analytic/advance.py (3)</summary><blockquote>

<details>
<summary>apiserver/plane/app/views/analytic/project_analytics.py (4)</summary>

* `get` (97-108)
* `get` (198-213)
* `get` (365-419)
* `work_item_completion_chart` (217-362)

</details>
<details>
<summary>apiserver/plane/db/models/issue.py (1)</summary>

* `Issue` (104-253)

</details>
<details>
<summary>apiserver/plane/db/models/workspace.py (1)</summary>

* `Workspace` (117-183)

</details>

</blockquote></details>

</details><details>
<summary>🪛 Ruff (0.11.9)</summary>

<details>
<summary>apiserver/plane/app/views/analytic/project_analytics.py</summary>

62-62: Line too long (100 > 88)

(E501)

---

164-164: Line too long (107 > 88)

(E501)

---

168-168: Line too long (91 > 88)

(E501)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (10)</summary><blockquote>

<details>
<summary>apiserver/plane/app/views/__init__.py (1)</summary>

`208-212`: **Imports added correctly for new project analytics endpoints.**

The new project-level advanced analytics API endpoints are properly imported, which aligns with the PR's objective of separating analytics functionality into project-level and workspace-level endpoints.

</details>
<details>
<summary>web/core/components/analytics-v2/total-insights.tsx (1)</summary>

`29-39`: **API parameter restructuring looks good.**

The change properly separates the `isPeekView` flag from the filter parameters object, passing it as a dedicated argument. This aligns with the service method refactoring that centralizes URL construction based on this flag.

</details>
<details>
<summary>web/core/components/analytics-v2/overview/project-insights.tsx (1)</summary>

`37-44`: **Parameter restructuring matches service method refactoring.**

The modification to pass `isPeekView` as a separate parameter instead of embedding it in the options object is consistent with the changes made in other analytics components and aligns with the updated service method signature.

</details>
<details>
<summary>web/core/components/analytics-v2/work-items/created-vs-resolved.tsx (1)</summary>

`31-38`: **Consistent parameter structure change.**

The change follows the same pattern seen in other analytics components, separating the `isPeekView` flag from the filter parameters object for better URL routing support. This is part of the coordinated effort to optimize analytics endpoints.

</details>
<details>
<summary>web/core/components/analytics-v2/work-items/workitems-insight-table.tsx (1)</summary>

`34-44`: **Updated service call to use the new parameter structure**

The code correctly implements the refactored service method signature, moving the `isPeekView` flag from the options object to a separate parameter. This change aligns with the broader refactoring of the analytics service methods.

</details>
<details>
<summary>web/core/components/analytics-v2/work-items/priority-chart.tsx (1)</summary>

`60-71`: **Updated service call to use the new parameter structure**

The code correctly implements the refactored service method signature, moving the `isPeekView` flag from the options object to a separate parameter. This change aligns with the broader refactoring of the analytics service methods.

</details>
<details>
<summary>apiserver/plane/app/urls/analytic.py (2)</summary>

`14-16`: **New project-level analytics endpoints imported**

The import statements correctly include the new project-level analytics view classes.

---

`73-87`: **New URL patterns for project-level analytics**

The added URL patterns provide clear project-scoped endpoints for advanced analytics. The implementation follows Django's URL pattern conventions and maintains a consistent naming scheme with the existing workspace-level endpoints.

</details>
<details>
<summary>web/core/services/analytics-v2.service.ts (2)</summary>

`13-14`: **Updated method signatures with isPeekView parameter**

The service methods have been correctly updated to accept `isPeekView` as a separate parameter instead of including it in the options object. This change supports the new URL structure for project-level analytics.



Also applies to: 34-35, 59-60

---

`16-24`: **Updated service methods to use new URL helper**

The service methods now use the new `processUrl` helper to dynamically construct the API endpoint URL based on the peek view status and tab type.



Also applies to: 37-44, 62-69

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T11:01:40Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 5**

<details>
<summary>♻️ Duplicate comments (4)</summary><blockquote>

<details>
<summary>apiserver/plane/app/views/analytic/project_analytics.py (4)</summary><blockquote>

`276-286`: _⚠️ Potential issue_

**Incorrect field path in aggregation filter**

The field path in the aggregation is incorrect. The `state` field belongs directly to the `Issue` model, not to a related `issue` field.



```diff
                    completed_count=Count(
-                        "id", filter=Q(issue__state__group="completed")
+                        "id", filter=Q(state__group="completed")
                    ),
```

---

`245-256`: _⚠️ Potential issue_

**`queryset` becomes a list and breaks ORM calls**

At line 255, you assign `queryset = cycle_issues`, which is a `ValuesListQuerySet` of IDs. Later at line 279, the code attempts to call `queryset.values("created_at__date")`, which will raise an `AttributeError`.



```diff
-            queryset = cycle_issues
+            queryset = Issue.issue_objects.filter(id__in=cycle_issues)
```

---

`237-243`: _⚠️ Potential issue_

**Queryset not scoped to the requested project**

The base queryset isn't filtered by project_id, causing the "project analytics" endpoint to potentially return workspace-wide numbers.



```diff
        queryset = (
            Issue.issue_objects.filter(**self.filters["base_filters"])
+            .filter(project_id=project_id)
            .select_related("workspace", "state", "parent")
            .prefetch_related(
                "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
            )
        )
```

---

`257-268`: _⚠️ Potential issue_

**Same `queryset` assignment issue for module_issues**

Similar to the cycle_issues case, at line 268 the code assigns `queryset = module_issues`, which will lead to the same `AttributeError`.


```diff
-            queryset = module_issues
+            queryset = Issue.issue_objects.filter(id__in=module_issues)
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>apiserver/plane/app/views/analytic/project_analytics.py (2)</summary><blockquote>

`62-63`: **Minor: Docstring inaccurately mentions workspace instead of project**

The docstring states "Returns work item stats for the workspace..." but this method actually returns project-level stats as suggested by both the class name and the `project_id` parameter.


```diff
-        Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
+        Returns work item stats for the project, or filtered by cycle_id or module_id if provided.
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.11.9)</summary>

62-62: Line too long (100 > 88)

(E501)

</details>

</details>

---

`381-383`: **Incorrect default type value**

The default value for `type` is "projects", but the endpoint only handles "custom-work-items" and "work-items" types, returning a 400 for any other value.


```diff
    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
        self.initialize_workspace(slug, type="chart")
-        type = request.GET.get("type", "projects")
+        type = request.GET.get("type", "work-items")
        group_by = request.GET.get("group_by", None)
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between c69bd614abe4ea5be149c85bfe0d2e6a0bf8986d and ab2e4b6a83dd78410d1c6f93a09a2e5efbf5ccfd.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `apiserver/plane/app/views/analytic/advance.py` (7 hunks)
* `apiserver/plane/app/views/analytic/project_analytics.py` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* apiserver/plane/app/views/analytic/advance.py

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 Ruff (0.11.9)</summary>

<details>
<summary>apiserver/plane/app/views/analytic/project_analytics.py</summary>

62-62: Line too long (100 > 88)

(E501)

---

171-171: Line too long (107 > 88)

(E501)

---

175-175: Line too long (91 > 88)

(E501)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (2)</summary><blockquote>

<details>
<summary>apiserver/plane/app/views/analytic/project_analytics.py (2)</summary>

`69-74`: **LGTM! Fixed issue handling from previous cycle/module filters**

The implementation correctly filters issues by their IDs from cycle_issues/module_issues, which resolves the potential AttributeError when calling values() on a ValuesListQuerySet.

---

`76-78`: **LGTM! Project scoping added correctly**

The queryset is properly filtered by `project_id` when neither cycle nor module is specified, which ensures analytics are correctly scoped to the project level.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T11:29:22Z
user: login: dheeru0198
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-22T12:03:36Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>♻️ Duplicate comments (8)</summary><blockquote>

<details>
<summary>apiserver/plane/app/views/analytic/project_analytics.py (7)</summary><blockquote>

`261-271`: _⚠️ Potential issue_

**Incorrect field path in aggregation filter.**

Using `Q(issue__state__group="completed")` is incorrect since state belongs to the Issue model directly, not through an 'issue' relation. This will cause the completed_count to always be zero.


```diff
-                    completed_count=Count(
-                        "id", filter=Q(issue__state__group="completed")
-                    ),
+                    completed_count=Count(
+                        "id", filter=Q(state__group="completed")
+                    ),
```

---

`230-241`: _⚠️ Potential issue_

**QuerySet replaced with list of IDs breaking ORM chain.**

Assigning `queryset = cycle_issues` overwrites the QuerySet with a ValuesListQuerySet of IDs. This will cause an AttributeError when `queryset.values()` is called a few lines later.


```diff
-            queryset = cycle_issues
+            queryset = Issue.issue_objects.filter(id__in=cycle_issues)
```

---

`254-260`: _⚠️ Potential issue_

**Missing end_date initialization in project branch.**

The end_date variable is not initialized in the project branch, but it's required for the date loop that follows.


```diff
        else:
            project = Project.objects.filter(id=project_id).first()
            if project.created_at:
                start_date = project.created_at.date().replace(day=1)
+                end_date = timezone.now().date()
            else:
                return {"data": [], "schema": {}}
```

---

`302-307`: _⚠️ Potential issue_

**Missing project_id filter in monthly chart data.**

When filtering by date range for monthly charts, the code doesn't filter by project_id, potentially returning workspace-wide data instead of project-specific data.


```diff
            if self.filters["chart_period_range"]:
                start_date, end_date = self.filters["chart_period_range"]
                queryset = queryset.filter(
-                    created_at__date__gte=start_date, created_at__date__lte=end_date
+                    created_at__date__gte=start_date, created_at__date__lte=end_date,
+                    project_id=project_id
                )
```

---

`365-373`: _🛠️ Refactor suggestion_

**Add project validation in the GET method.**

The endpoint should verify that the user has access to the requested project before returning analytics data.


```diff
    @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
        self.initialize_workspace(slug, type="chart")
+        # Verify project access
+        project = Project.objects.filter(
+            workspace__slug=slug, id=project_id
+        ).first()
+        if not project:
+            return Response(
+                {"error": "Project not found"}, 
+                status=status.HTTP_404_NOT_FOUND
+            )
        
        type = request.GET.get("type", "projects")
```

---

`341-349`: _🛠️ Refactor suggestion_

**Count schema inconsistency in monthly chart data.**

The "count" field in the monthly data (line 345) only includes created_count, while the daily stats (line 295) sum both created and completed counts. This inconsistency could confuse users.


```diff
                data.append(
                    {
                        "key": date_str,
                        "name": date_str,
-                        "count": stats["created_count"],
+                        "count": stats["created_count"] + stats["completed_count"],
                        "completed_issues": stats["completed_count"],
                        "created_issues": stats["created_count"],
                    }
                )
```

---

`243-253`: _⚠️ Potential issue_

**Same QuerySet issue in module branch.**

Similar to the cycle branch, this assigns `queryset = module_issues` which will break ORM chaining.

```diff
-            queryset = module_issues
+            queryset = Issue.issue_objects.filter(id__in=module_issues)
```

</blockquote></details>
<details>
<summary>apiserver/plane/app/views/analytic/advance.py (1)</summary><blockquote>

`175-188`: _⚠️ Potential issue_

**Missing date-range filtering in work items stats.**

Unlike `get_project_issues_stats`, the `get_work_items_stats` method doesn't apply the chart_period_range filter. This will cause it to return all-time numbers while other charts/tables honor the user's date filter.


```diff
    def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
+        # Apply date range filter if available
+        if self.filters["chart_period_range"]:
+            start_date, end_date = self.filters["chart_period_range"]
+            base_queryset = base_queryset.filter(
+                created_at__date__gte=start_date, created_at__date__lte=end_date
+            )
        return (
            base_queryset
            .values("project_id", "project__name")
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>apiserver/plane/app/views/analytic/project_analytics.py (2)</summary><blockquote>

`58-63`: **Docstring exceeds line length limit.**

The docstring on line 62 exceeds the 88 character line length limit recommended by style guidelines.

```diff
-        """
-        Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
-        """
+        """
+        Returns work item stats for the workspace, or filtered by cycle_id or module_id 
+        if provided.
+        """
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.11.9)</summary>

62-62: Line too long (100 > 88)

(E501)

</details>

</details>

---

`158-175`: **Consider adding custom avatar_url logic to a model or utility function.**

The avatar_url annotation logic is complex and may be reused elsewhere. Consider extracting it to a model method or utility function for better maintainability.

```diff
# In a utils file or model method
+def annotate_avatar_url(queryset):
+    return queryset.annotate(
+        avatar_url=Case(
+            When(
+                assignees__avatar_asset__isnull=False,
+                then=Concat(
+                    Value("/api/assets/v2/static/"),
+                    "assignees__avatar_asset",
+                    Value("/"),
+                ),
+            ),
+            When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
+            default=Value(None),
+            output_field=models.CharField(),
+        )
+    )

# Then in your view:
-            .annotate(
-                avatar_url=Case(
-                    # If `avatar_asset` exists, use it to generate the asset URL
-                    When(
-                        assignees__avatar_asset__isnull=False,
-                        then=Concat(
-                            Value("/api/assets/v2/static/"),
-                            "assignees__avatar_asset",  # Assuming avatar_asset has an id or relevant field
-                            Value("/"),
-                        ),
-                    ),
-                    # If `avatar_asset` is None, fall back to using `avatar` field directly
-                    When(
-                        assignees__avatar_asset__isnull=True, then="assignees__avatar"
-                    ),
-                    default=Value(None),
-                    output_field=models.CharField(),
-                )
-            )
+            .annotate_avatar_url()
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.11.9)</summary>

164-164: Line too long (107 > 88)

(E501)

---

168-168: Line too long (91 > 88)

(E501)

</details>

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between ab2e4b6a83dd78410d1c6f93a09a2e5efbf5ccfd and 7b41e84e4fd20807ad3797dbfbb441fde4b0bd1c.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `apiserver/plane/app/views/analytic/advance.py` (8 hunks)
* `apiserver/plane/app/views/analytic/project_analytics.py` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 Ruff (0.11.9)</summary>

<details>
<summary>apiserver/plane/app/views/analytic/project_analytics.py</summary>

62-62: Line too long (100 > 88)

(E501)

---

164-164: Line too long (107 > 88)

(E501)

---

168-168: Line too long (91 > 88)

(E501)

</details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (1)</summary>

* GitHub Check: Analyze (javascript)

</details>

<details>
<summary>🔇 Additional comments (8)</summary><blockquote>

<details>
<summary>apiserver/plane/app/views/analytic/project_analytics.py (3)</summary>

`1-26`: **Well-organized imports with clear categorization.**

The imports are logically grouped (standard library, Django, app models, utilities) which follows good practices for import organization.

---

`28-37`: **Good base class implementation.**

The base class properly initializes the workspace context and filters, providing a solid foundation for the derived analytics views.

---

`375-382`: **Add project_id filter to custom-work-items queryset.**

The queryset for custom-work-items already contains a filter for project_id on line 377, which is good. This ensures the endpoint returns project-specific data.

</details>
<details>
<summary>apiserver/plane/app/views/analytic/advance.py (5)</summary>

`115-116`: **Simplified workspace-level analytics method.**

The `get_work_items_stats` method has been simplified to no longer accept parameters, which aligns with the separation of concerns between workspace and project analytics.

---

`146-147`: **Updated method call signature.**

The method call has been updated to match the new signature with no parameters, correctly reflecting the changes made to the method definition.

---

`267-269`: **Workspace creation date used as chart start date.**

Using the workspace creation date as the start date for charts provides a consistent baseline for all workspace analytics. Good choice for data continuity.

---

`270-276`: **Properly applying date filters to chart data.**

The code correctly applies date range filters to the queryset when available, ensuring that the chart reflects the user's selected time period.

---

`364-365`: **Updated method call in chart endpoint.**

The method call has been updated to match the new signature with no parameters, correctly reflecting the changes made to the method definition.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T12:13:31Z
user: login: dheeru0198
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-22T12:32:42Z

Review comments:
diff_hunk: @@ -57,4 +77,19 @@ export class AnalyticsV2Service extends APIService {
         throw err?.response?.data;
       });
   }
+
+  processUrl<T extends string>(
+    endpoint: string,
+    workspaceSlug: string,
+    tab: T,
+    params?: Record<string, any>,
+    isPeekView?: boolean
+  ) {
+    let processedUrl = `/api/workspaces/${workspaceSlug}`;
+    if (isPeekView && tab === "work-items") {
+      const projectId = params?.project_ids.split(",")[0];
+      processedUrl += `/projects/${projectId}`;
+    }
+    return `${processedUrl}/${endpoint}`;
+  }
path: web/core/services/analytics-v2.service.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Add check for project_ids existence in processUrl method**

The new `processUrl` helper method conditionally constructs URLs for project-level analytics but doesn't verify if `project_ids` exists or is non-empty before attempting to use it. This could lead to runtime errors if `isPeekView` is true but no projects are selected.

Add a check to ensure `project_ids` exists and is not empty:

```diff
  processUrl<T extends string>(
    endpoint: string,
    workspaceSlug: string,
    tab: T,
    params?: Record<string, any>,
    isPeekView?: boolean
  ) {
    let processedUrl = `/api/workspaces/${workspaceSlug}`;
-   if (isPeekView && tab === "work-items") {
+   if (isPeekView && tab === "work-items" && params?.project_ids) {
      const projectId = params?.project_ids.split(",")[0];
      processedUrl += `/projects/${projectId}`;
    }
    return `${processedUrl}/${endpoint}`;
  }
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
// web/core/services/analytics-v2.service.ts

processUrl<T extends string>(
  endpoint: string,
  workspaceSlug: string,
  tab: T,
  params?: Record<string, any>,
  isPeekView?: boolean
) {
  let processedUrl = `/api/workspaces/${workspaceSlug}`;
  if (isPeekView && tab === "work-items" && params?.project_ids) {
    const projectId = params.project_ids.split(",")[0];
    processedUrl += `/projects/${projectId}`;
  }
  return `${processedUrl}/${endpoint}`;
}
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In web/core/services/analytics-v2.service.ts around lines 81 to 94, the
processUrl method uses params?.project_ids without verifying if project_ids
exists or is non-empty, which can cause runtime errors when isPeekView is true
but no projects are selected. To fix this, add a check to confirm that
params.project_ids is defined and not an empty string before accessing and
splitting it. Only proceed with constructing the project-specific URL if this
condition is met.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T11:01:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -199,84 +173,18 @@ def get_project_issues_stats(self) -> QuerySet:
             .order_by("project_id")
         )
 
-    def get_work_items_stats(
-        self, cycle_id=None, module_id=None, peek_view=False
-    ) -> Dict[str, Dict[str, int]]:
-        base_queryset = None
-        if cycle_id is not None:
-            cycle_issues = CycleIssue.objects.filter(
-                **self.filters["base_filters"], cycle_id=cycle_id
-            ).values_list("issue_id", flat=True)
-            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
-        elif module_id is not None:
-            module_issues = ModuleIssue.objects.filter(
-                **self.filters["base_filters"], module_id=module_id
-            ).values_list("issue_id", flat=True)
-            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
-        elif peek_view:
-            base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
-        else:
-            base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
-            return (
-                base_queryset.values("project_id", "project__name")
-                .annotate(
-                    cancelled_work_items=Count(
-                        "id", filter=Q(state__group="cancelled")
-                    ),
-                    completed_work_items=Count(
-                        "id", filter=Q(state__group="completed")
-                    ),
-                    backlog_work_items=Count("id", filter=Q(state__group="backlog")),
-                    un_started_work_items=Count(
-                        "id", filter=Q(state__group="unstarted")
-                    ),
-                    started_work_items=Count("id", filter=Q(state__group="started")),
-                )
-                .order_by("project_id")
-            )
-
+    def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
+        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
         return (
-            base_queryset.annotate(display_name=F("assignees__display_name"))
-            .annotate(assignee_id=F("assignees__id"))
-            .annotate(avatar=F("assignees__avatar"))
-            .annotate(
-                avatar_url=Case(
-                    # If `avatar_asset` exists, use it to generate the asset URL
-                    When(
-                        assignees__avatar_asset__isnull=False,
-                        then=Concat(
-                            Value("/api/assets/v2/static/"),
-                            "assignees__avatar_asset",  # Assuming avatar_asset has an id or relevant field
-                            Value("/"),
-                        ),
-                    ),
-                    # If `avatar_asset` is None, fall back to using `avatar` field directly
-                    When(
-                        assignees__avatar_asset__isnull=True, then="assignees__avatar"
-                    ),
-                    default=Value(None),
-                    output_field=models.CharField(),
-                )
-            )
-            .values("display_name", "assignee_id", "avatar_url")
+            base_queryset.values("project_id", "project__name")
             .annotate(
-                cancelled_work_items=Count(
-                    "id", filter=Q(state__group="cancelled"), distinct=True
-                ),
-                completed_work_items=Count(
-                    "id", filter=Q(state__group="completed"), distinct=True
-                ),
-                backlog_work_items=Count(
-                    "id", filter=Q(state__group="backlog"), distinct=True
-                ),
-                un_started_work_items=Count(
-                    "id", filter=Q(state__group="unstarted"), distinct=True
-                ),
-                started_work_items=Count(
-                    "id", filter=Q(state__group="started"), distinct=True
-                ),
+                cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
+                completed_work_items=Count("id", filter=Q(state__group="completed")),
+                backlog_work_items=Count("id", filter=Q(state__group="backlog")),
+                un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
+                started_work_items=Count("id", filter=Q(state__group="started")),
             )
-            .order_by("display_name")
+            .order_by("project_id")
         )
path: apiserver/plane/app/views/analytic/advance.py
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

_⚠️ Potential issue_

**Date-range filtering dropped in the stats endpoint**

`AdvanceAnalyticsStatsEndpoint.get_work_items_stats` no longer applies `chart_period_range`, unlike `get_project_issues_stats` directly above.  
This will produce *all-time* numbers while the rest of the charts/tables in the same screen honour the user’s date filter, leading to confusing, apparently inconsistent data.

A quick fix is to replicate the same slice applied in `get_project_issues_stats`:

```diff
-        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
+        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
+        if self.filters["chart_period_range"]:
+            start_date, end_date = self.filters["chart_period_range"]
+            base_queryset = base_queryset.filter(
+                created_at__date__gte=start_date, created_at__date__lte=end_date
+            )
```




<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
    def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
        if self.filters["chart_period_range"]:
            start_date, end_date = self.filters["chart_period_range"]
            base_queryset = base_queryset.filter(
                created_at__date__gte=start_date, created_at__date__lte=end_date
            )
        return (
            base_queryset.values("project_id", "project__name")
            .annotate(
                cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
                completed_work_items=Count("id", filter=Q(state__group="completed")),
                backlog_work_items=Count("id", filter=Q(state__group="backlog")),
                un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
                started_work_items=Count("id", filter=Q(state__group="started")),
            )
            .order_by("project_id")
        )
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apiserver/plane/app/views/analytic/advance.py around lines 176 to 188, the
get_work_items_stats method is missing the date-range filtering applied in
get_project_issues_stats, causing it to return all-time data instead of data
filtered by the user's selected date range. To fix this, apply the same
chart_period_range filter used in get_project_issues_stats to the base_queryset
in get_work_items_stats, ensuring the queryset is filtered by the appropriate
date range before aggregation.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T11:01:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,419 @@
+from rest_framework.response import Response
+from rest_framework import status
+from typing import Dict, Any
+from django.db.models import QuerySet, Q, Count
+from django.http import HttpRequest
+from django.db.models.functions import TruncMonth
+from django.utils import timezone
+from datetime import timedelta
+from plane.app.views.base import BaseAPIView
+from plane.app.permissions import ROLE, allow_permission
+from plane.db.models import (
+    Project,
+    Issue,
+    Cycle,
+    Module,
+    CycleIssue,
+    ModuleIssue,
+)
+from django.db import models
+from django.db.models import F, Case, When, Value
+from django.db.models.functions import Concat
+from plane.utils.build_chart import build_analytics_chart
+from plane.utils.date_utils import (
+    get_analytics_filters,
+)
+
+
+class ProjectAdvanceAnalyticsBaseView(BaseAPIView):
+    def initialize_workspace(self, slug: str, type: str) -> None:
+        self._workspace_slug = slug
+        self.filters = get_analytics_filters(
+            slug=slug,
+            type=type,
+            user=self.request.user,
+            date_filter=self.request.GET.get("date_filter", None),
+            project_ids=self.request.GET.get("project_ids", None),
+        )
+
+
+class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
+        def get_filtered_count() -> int:
+            if self.filters["analytics_date_range"]:
+                return queryset.filter(
+                    created_at__gte=self.filters["analytics_date_range"]["current"][
+                        "gte"
+                    ],
+                    created_at__lte=self.filters["analytics_date_range"]["current"][
+                        "lte"
+                    ],
+                ).count()
+            return queryset.count()
+
+        return {
+            "count": get_filtered_count(),
+        }
+
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        """
+        Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
+        """
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+
+        return {
+            "total_work_items": self.get_filtered_counts(base_queryset),
+            "started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="started")
+            ),
+            "backlog_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="backlog")
+            ),
+            "un_started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="unstarted")
+            ),
+            "completed_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="completed")
+            ),
+        }
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="analytics")
+
+        # Optionally accept cycle_id or module_id as query params
+        cycle_id = request.GET.get("cycle_id", None)
+        module_id = request.GET.get("module_id", None)
+        return Response(
+            self.get_work_items_stats(
+                cycle_id=cycle_id, module_id=module_id, project_id=project_id
+            ),
+            status=status.HTTP_200_OK,
+        )
+
+
+class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_project_issues_stats(self) -> QuerySet:
+        # Get the base queryset with workspace and project filters
+        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
+
+        # Apply date range filter if available
+        if self.filters["chart_period_range"]:
+            start_date, end_date = self.filters["chart_period_range"]
+            base_queryset = base_queryset.filter(
+                created_at__date__gte=start_date, created_at__date__lte=end_date
+            )
+
+        return (
+            base_queryset.values("project_id", "project__name")
+            .annotate(
+                cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
+                completed_work_items=Count("id", filter=Q(state__group="completed")),
+                backlog_work_items=Count("id", filter=Q(state__group="backlog")),
+                un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
+                started_work_items=Count("id", filter=Q(state__group="started")),
+            )
+            .order_by("project_id")
+        )
+
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+        return (
+            base_queryset.annotate(display_name=F("assignees__display_name"))
+            .annotate(assignee_id=F("assignees__id"))
+            .annotate(avatar=F("assignees__avatar"))
+            .annotate(
+                avatar_url=Case(
+                    # If `avatar_asset` exists, use it to generate the asset URL
+                    When(
+                        assignees__avatar_asset__isnull=False,
+                        then=Concat(
+                            Value("/api/assets/v2/static/"),
+                            "assignees__avatar_asset",  # Assuming avatar_asset has an id or relevant field
+                            Value("/"),
+                        ),
+                    ),
+                    # If `avatar_asset` is None, fall back to using `avatar` field directly
+                    When(
+                        assignees__avatar_asset__isnull=True, then="assignees__avatar"
+                    ),
+                    default=Value(None),
+                    output_field=models.CharField(),
+                )
+            )
+            .values("display_name", "assignee_id", "avatar_url")
+            .annotate(
+                cancelled_work_items=Count(
+                    "id", filter=Q(state__group="cancelled"), distinct=True
+                ),
+                completed_work_items=Count(
+                    "id", filter=Q(state__group="completed"), distinct=True
+                ),
+                backlog_work_items=Count(
+                    "id", filter=Q(state__group="backlog"), distinct=True
+                ),
+                un_started_work_items=Count(
+                    "id", filter=Q(state__group="unstarted"), distinct=True
+                ),
+                started_work_items=Count(
+                    "id", filter=Q(state__group="started"), distinct=True
+                ),
+            )
+            .order_by("display_name")
+        )
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="chart")
+        type = request.GET.get("type", "work-items")
+
+        if type == "work-items":
+            # Optionally accept cycle_id or module_id as query params
+            cycle_id = request.GET.get("cycle_id", None)
+            module_id = request.GET.get("module_id", None)
+            return Response(
+                self.get_work_items_stats(
+                    project_id=project_id, cycle_id=cycle_id, module_id=module_id
+                ),
+                status=status.HTTP_200_OK,
+            )
+
+        return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
+
+
+class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def work_item_completion_chart(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Any]:
+        # Get the base queryset
+        queryset = (
+            Issue.issue_objects.filter(**self.filters["base_filters"])
+            .select_related("workspace", "state", "parent")
+            .prefetch_related(
+                "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
+            )
+        )
+
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            cycle = Cycle.objects.filter(id=cycle_id).first()
+            if cycle and cycle.start_date:
+                start_date = cycle.start_date.date()
+                end_date = cycle.end_date.date()
+            else:
+                return {"data": [], "schema": {}}
+            queryset = cycle_issues
+
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            module = Module.objects.filter(id=module_id).first()
+            if module and module.start_date:
+                start_date = module.start_date
+                end_date = module.target_date
+            else:
+                return {"data": [], "schema": {}}
+            queryset = module_issues
+
+        else:
+            project = Project.objects.filter(id=project_id).first()
+            if project.created_at:
+                start_date = project.created_at.date().replace(day=1)
+            else:
+                return {"data": [], "schema": {}}
+
+        if cycle_id or module_id:
+            # Get daily stats with optimized query
+            daily_stats = (
+                queryset.values("created_at__date")
+                .annotate(
+                    created_count=Count("id"),
+                    completed_count=Count(
+                        "id", filter=Q(issue__state__group="completed")
+                    ),
+                )
+                .order_by("created_at__date")
path: apiserver/plane/app/views/analytic/project_analytics.py
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Incorrect field path in aggregation filter**

`Q(issue__state__group="completed")` is wrong — `state` belongs to the `Issue` itself, not to a related `issue`.  
The filter never matches, so `completed_count` is always zero.

```diff
-                    completed_count=Count(
-                        "id", filter=Q(issue__state__group="completed")
-                    ),
+                    completed_count=Count(
+                        "id", filter=Q(state__group="completed")
+                    ),
```



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
        if cycle_id or module_id:
            # Get daily stats with optimized query
            daily_stats = (
                queryset.values("created_at__date")
                .annotate(
                    created_count=Count("id"),
                    completed_count=Count(
                        "id", filter=Q(state__group="completed")
                    ),
                )
                .order_by("created_at__date")
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apiserver/plane/app/views/analytic/project_analytics.py around lines 260 to
270, the filter in the Count aggregation uses an incorrect field path
Q(issue__state__group="completed") where 'state' is accessed through 'issue',
but 'state' actually belongs directly to the Issue model. Fix this by removing
the extra 'issue__' prefix so the filter reads Q(state__group="completed") to
correctly count completed issues.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 0a8cc24 to ab2e4b6
created_at: 2025-05-22T11:01:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,419 @@
+from rest_framework.response import Response
+from rest_framework import status
+from typing import Dict, Any
+from django.db.models import QuerySet, Q, Count
+from django.http import HttpRequest
+from django.db.models.functions import TruncMonth
+from django.utils import timezone
+from datetime import timedelta
+from plane.app.views.base import BaseAPIView
+from plane.app.permissions import ROLE, allow_permission
+from plane.db.models import (
+    Project,
+    Issue,
+    Cycle,
+    Module,
+    CycleIssue,
+    ModuleIssue,
+)
+from django.db import models
+from django.db.models import F, Case, When, Value
+from django.db.models.functions import Concat
+from plane.utils.build_chart import build_analytics_chart
+from plane.utils.date_utils import (
+    get_analytics_filters,
+)
+
+
+class ProjectAdvanceAnalyticsBaseView(BaseAPIView):
+    def initialize_workspace(self, slug: str, type: str) -> None:
+        self._workspace_slug = slug
+        self.filters = get_analytics_filters(
+            slug=slug,
+            type=type,
+            user=self.request.user,
+            date_filter=self.request.GET.get("date_filter", None),
+            project_ids=self.request.GET.get("project_ids", None),
+        )
+
+
+class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
+        def get_filtered_count() -> int:
+            if self.filters["analytics_date_range"]:
+                return queryset.filter(
+                    created_at__gte=self.filters["analytics_date_range"]["current"][
+                        "gte"
+                    ],
+                    created_at__lte=self.filters["analytics_date_range"]["current"][
+                        "lte"
+                    ],
+                ).count()
+            return queryset.count()
+
+        return {
+            "count": get_filtered_count(),
+        }
+
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        """
+        Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
+        """
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+
+        return {
+            "total_work_items": self.get_filtered_counts(base_queryset),
+            "started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="started")
+            ),
+            "backlog_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="backlog")
+            ),
+            "un_started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="unstarted")
+            ),
+            "completed_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="completed")
+            ),
+        }
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="analytics")
+
+        # Optionally accept cycle_id or module_id as query params
+        cycle_id = request.GET.get("cycle_id", None)
+        module_id = request.GET.get("module_id", None)
+        return Response(
+            self.get_work_items_stats(
+                cycle_id=cycle_id, module_id=module_id, project_id=project_id
+            ),
+            status=status.HTTP_200_OK,
+        )
+
+
+class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_project_issues_stats(self) -> QuerySet:
+        # Get the base queryset with workspace and project filters
+        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
+
+        # Apply date range filter if available
+        if self.filters["chart_period_range"]:
+            start_date, end_date = self.filters["chart_period_range"]
+            base_queryset = base_queryset.filter(
+                created_at__date__gte=start_date, created_at__date__lte=end_date
+            )
+
+        return (
+            base_queryset.values("project_id", "project__name")
+            .annotate(
+                cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
+                completed_work_items=Count("id", filter=Q(state__group="completed")),
+                backlog_work_items=Count("id", filter=Q(state__group="backlog")),
+                un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
+                started_work_items=Count("id", filter=Q(state__group="started")),
+            )
+            .order_by("project_id")
+        )
+
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+        return (
+            base_queryset.annotate(display_name=F("assignees__display_name"))
+            .annotate(assignee_id=F("assignees__id"))
+            .annotate(avatar=F("assignees__avatar"))
+            .annotate(
+                avatar_url=Case(
+                    # If `avatar_asset` exists, use it to generate the asset URL
+                    When(
+                        assignees__avatar_asset__isnull=False,
+                        then=Concat(
+                            Value("/api/assets/v2/static/"),
+                            "assignees__avatar_asset",  # Assuming avatar_asset has an id or relevant field
+                            Value("/"),
+                        ),
+                    ),
+                    # If `avatar_asset` is None, fall back to using `avatar` field directly
+                    When(
+                        assignees__avatar_asset__isnull=True, then="assignees__avatar"
+                    ),
+                    default=Value(None),
+                    output_field=models.CharField(),
+                )
+            )
+            .values("display_name", "assignee_id", "avatar_url")
+            .annotate(
+                cancelled_work_items=Count(
+                    "id", filter=Q(state__group="cancelled"), distinct=True
+                ),
+                completed_work_items=Count(
+                    "id", filter=Q(state__group="completed"), distinct=True
+                ),
+                backlog_work_items=Count(
+                    "id", filter=Q(state__group="backlog"), distinct=True
+                ),
+                un_started_work_items=Count(
+                    "id", filter=Q(state__group="unstarted"), distinct=True
+                ),
+                started_work_items=Count(
+                    "id", filter=Q(state__group="started"), distinct=True
+                ),
+            )
+            .order_by("display_name")
+        )
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="chart")
+        type = request.GET.get("type", "work-items")
+
+        if type == "work-items":
+            # Optionally accept cycle_id or module_id as query params
+            cycle_id = request.GET.get("cycle_id", None)
+            module_id = request.GET.get("module_id", None)
+            return Response(
+                self.get_work_items_stats(
+                    project_id=project_id, cycle_id=cycle_id, module_id=module_id
+                ),
+                status=status.HTTP_200_OK,
+            )
+
+        return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
+
+
+class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def work_item_completion_chart(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Any]:
+        # Get the base queryset
+        queryset = (
+            Issue.issue_objects.filter(**self.filters["base_filters"])
+            .select_related("workspace", "state", "parent")
+            .prefetch_related(
+                "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
+            )
+        )
+
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            cycle = Cycle.objects.filter(id=cycle_id).first()
+            if cycle and cycle.start_date:
+                start_date = cycle.start_date.date()
+                end_date = cycle.end_date.date()
+            else:
+                return {"data": [], "schema": {}}
+            queryset = cycle_issues
+
path: apiserver/plane/app/views/analytic/project_analytics.py
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**`queryset` becomes a list and breaks ORM calls**

You overwrite `queryset` with `cycle_issues` / `module_issues`, which is a `ValuesListQuerySet` of IDs.  
A few lines later you call `queryset.values(...)`, which will raise `AttributeError` because `ValuesListQuerySet` lacks `values`.

```diff
-            queryset = cycle_issues
+            queryset = Issue.issue_objects.filter(id__in=cycle_issues)
```

Make the same change for the module branch.




<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
        if cycle_id is not None:
            cycle_issues = CycleIssue.objects.filter(
                **self.filters["base_filters"], cycle_id=cycle_id
            ).values_list("issue_id", flat=True)
            cycle = Cycle.objects.filter(id=cycle_id).first()
            if cycle and cycle.start_date:
                start_date = cycle.start_date.date()
                end_date = cycle.end_date.date()
            else:
                return {"data": [], "schema": {}}
-           queryset = cycle_issues
+           queryset = Issue.issue_objects.filter(id__in=cycle_issues)
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apiserver/plane/app/views/analytic/project_analytics.py around lines 229 to
240, the variable queryset is overwritten with cycle_issues, which is a
ValuesListQuerySet of IDs, causing an AttributeError later when calling
queryset.values(). To fix this, do not assign cycle_issues directly to queryset;
instead, keep queryset as a QuerySet and filter it using the IDs from
cycle_issues. Apply the same fix for the module branch where module_issues is
assigned to queryset.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T11:01:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,419 @@
+from rest_framework.response import Response
+from rest_framework import status
+from typing import Dict, Any
+from django.db.models import QuerySet, Q, Count
+from django.http import HttpRequest
+from django.db.models.functions import TruncMonth
+from django.utils import timezone
+from datetime import timedelta
+from plane.app.views.base import BaseAPIView
+from plane.app.permissions import ROLE, allow_permission
+from plane.db.models import (
+    Project,
+    Issue,
+    Cycle,
+    Module,
+    CycleIssue,
+    ModuleIssue,
+)
+from django.db import models
+from django.db.models import F, Case, When, Value
+from django.db.models.functions import Concat
+from plane.utils.build_chart import build_analytics_chart
+from plane.utils.date_utils import (
+    get_analytics_filters,
+)
+
+
+class ProjectAdvanceAnalyticsBaseView(BaseAPIView):
+    def initialize_workspace(self, slug: str, type: str) -> None:
+        self._workspace_slug = slug
+        self.filters = get_analytics_filters(
+            slug=slug,
+            type=type,
+            user=self.request.user,
+            date_filter=self.request.GET.get("date_filter", None),
+            project_ids=self.request.GET.get("project_ids", None),
+        )
+
+
+class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
+        def get_filtered_count() -> int:
+            if self.filters["analytics_date_range"]:
+                return queryset.filter(
+                    created_at__gte=self.filters["analytics_date_range"]["current"][
+                        "gte"
+                    ],
+                    created_at__lte=self.filters["analytics_date_range"]["current"][
+                        "lte"
+                    ],
+                ).count()
+            return queryset.count()
+
+        return {
+            "count": get_filtered_count(),
+        }
+
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        """
+        Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
+        """
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+
+        return {
+            "total_work_items": self.get_filtered_counts(base_queryset),
+            "started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="started")
+            ),
+            "backlog_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="backlog")
+            ),
+            "un_started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="unstarted")
+            ),
+            "completed_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="completed")
+            ),
+        }
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="analytics")
+
+        # Optionally accept cycle_id or module_id as query params
+        cycle_id = request.GET.get("cycle_id", None)
+        module_id = request.GET.get("module_id", None)
+        return Response(
+            self.get_work_items_stats(
+                cycle_id=cycle_id, module_id=module_id, project_id=project_id
+            ),
+            status=status.HTTP_200_OK,
+        )
+
+
+class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_project_issues_stats(self) -> QuerySet:
+        # Get the base queryset with workspace and project filters
+        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
+
+        # Apply date range filter if available
+        if self.filters["chart_period_range"]:
+            start_date, end_date = self.filters["chart_period_range"]
+            base_queryset = base_queryset.filter(
+                created_at__date__gte=start_date, created_at__date__lte=end_date
+            )
+
+        return (
+            base_queryset.values("project_id", "project__name")
+            .annotate(
+                cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
+                completed_work_items=Count("id", filter=Q(state__group="completed")),
+                backlog_work_items=Count("id", filter=Q(state__group="backlog")),
+                un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
+                started_work_items=Count("id", filter=Q(state__group="started")),
+            )
+            .order_by("project_id")
+        )
+
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+        return (
+            base_queryset.annotate(display_name=F("assignees__display_name"))
+            .annotate(assignee_id=F("assignees__id"))
+            .annotate(avatar=F("assignees__avatar"))
+            .annotate(
+                avatar_url=Case(
+                    # If `avatar_asset` exists, use it to generate the asset URL
+                    When(
+                        assignees__avatar_asset__isnull=False,
+                        then=Concat(
+                            Value("/api/assets/v2/static/"),
+                            "assignees__avatar_asset",  # Assuming avatar_asset has an id or relevant field
+                            Value("/"),
+                        ),
+                    ),
+                    # If `avatar_asset` is None, fall back to using `avatar` field directly
+                    When(
+                        assignees__avatar_asset__isnull=True, then="assignees__avatar"
+                    ),
+                    default=Value(None),
+                    output_field=models.CharField(),
+                )
+            )
+            .values("display_name", "assignee_id", "avatar_url")
+            .annotate(
+                cancelled_work_items=Count(
+                    "id", filter=Q(state__group="cancelled"), distinct=True
+                ),
+                completed_work_items=Count(
+                    "id", filter=Q(state__group="completed"), distinct=True
+                ),
+                backlog_work_items=Count(
+                    "id", filter=Q(state__group="backlog"), distinct=True
+                ),
+                un_started_work_items=Count(
+                    "id", filter=Q(state__group="unstarted"), distinct=True
+                ),
+                started_work_items=Count(
+                    "id", filter=Q(state__group="started"), distinct=True
+                ),
+            )
+            .order_by("display_name")
+        )
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="chart")
+        type = request.GET.get("type", "work-items")
+
+        if type == "work-items":
+            # Optionally accept cycle_id or module_id as query params
+            cycle_id = request.GET.get("cycle_id", None)
+            module_id = request.GET.get("module_id", None)
+            return Response(
+                self.get_work_items_stats(
+                    project_id=project_id, cycle_id=cycle_id, module_id=module_id
+                ),
+                status=status.HTTP_200_OK,
+            )
+
+        return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
+
+
+class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def work_item_completion_chart(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Any]:
+        # Get the base queryset
+        queryset = (
+            Issue.issue_objects.filter(**self.filters["base_filters"])
+            .select_related("workspace", "state", "parent")
+            .prefetch_related(
+                "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
+            )
+        )
path: apiserver/plane/app/views/analytic/project_analytics.py
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

_⚠️ Potential issue_

**Queryset not scoped to the requested project**

When neither `cycle_id` nor `module_id` is supplied, `queryset` contains *all* issues in the workspace.  
Consequently the “project analytics” endpoint will return workspace-wide numbers, defeating its purpose.

```diff
-        queryset = (
-            Issue.issue_objects.filter(**self.filters["base_filters"])
+        queryset = (
+            Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
```




<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
        # Get the base queryset
        queryset = (
            Issue.issue_objects.filter(
                **self.filters["base_filters"], project_id=project_id
            )
            .select_related("workspace", "state", "parent")
            .prefetch_related(
                "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
            )
        )
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apiserver/plane/app/views/analytic/project_analytics.py around lines 220 to
227, the queryset is not filtered by the requested project when neither cycle_id
nor module_id is provided, causing it to include all issues in the workspace. To
fix this, add a filter condition to the queryset to restrict issues to the
specific project ID from the request parameters or context, ensuring the
analytics endpoint returns data scoped to the requested project only.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T11:01:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,435 @@
+from rest_framework.response import Response
+from rest_framework import status
+from typing import Dict, Any
+from django.db.models import QuerySet, Q, Count
+from django.http import HttpRequest
+from django.db.models.functions import TruncMonth
+from django.utils import timezone
+from datetime import timedelta
+from plane.app.views.base import BaseAPIView
+from plane.app.permissions import ROLE, allow_permission
+from plane.db.models import (
+    Project,
+    Issue,
+    Cycle,
+    Module,
+    CycleIssue,
+    ModuleIssue,
+)
+from django.db import models
+from django.db.models import F, Case, When, Value
+from django.db.models.functions import Concat
+from plane.utils.build_chart import build_analytics_chart
+from plane.utils.date_utils import (
+    get_analytics_filters,
+)
+
+
+class ProjectAdvanceAnalyticsBaseView(BaseAPIView):
+    def initialize_workspace(self, slug: str, type: str) -> None:
+        self._workspace_slug = slug
+        self.filters = get_analytics_filters(
+            slug=slug,
+            type=type,
+            user=self.request.user,
+            date_filter=self.request.GET.get("date_filter", None),
+            project_ids=self.request.GET.get("project_ids", None),
+        )
+
+
+class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
+        def get_filtered_count() -> int:
+            if self.filters["analytics_date_range"]:
+                return queryset.filter(
+                    created_at__gte=self.filters["analytics_date_range"]["current"][
+                        "gte"
+                    ],
+                    created_at__lte=self.filters["analytics_date_range"]["current"][
+                        "lte"
+                    ],
+                ).count()
+            return queryset.count()
+
+        return {
+            "count": get_filtered_count(),
+        }
+
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        """
+        Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
+        """
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+
+        return {
+            "total_work_items": self.get_filtered_counts(base_queryset),
+            "started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="started")
+            ),
+            "backlog_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="backlog")
+            ),
+            "un_started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="unstarted")
+            ),
+            "completed_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="completed")
+            ),
+        }
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="analytics")
+
+        # Optionally accept cycle_id or module_id as query params
+        cycle_id = request.GET.get("cycle_id", None)
+        module_id = request.GET.get("module_id", None)
+        return Response(
+            self.get_work_items_stats(
+                cycle_id=cycle_id, module_id=module_id, project_id=project_id
+            ),
+            status=status.HTTP_200_OK,
+        )
+
+
+class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_project_issues_stats(self) -> QuerySet:
+        # Get the base queryset with workspace and project filters
+        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
+
+        # Apply date range filter if available
+        if self.filters["chart_period_range"]:
+            start_date, end_date = self.filters["chart_period_range"]
+            base_queryset = base_queryset.filter(
+                created_at__date__gte=start_date, created_at__date__lte=end_date
+            )
+
+        return (
+            base_queryset.annotate(
+                cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
+                completed_work_items=Count("id", filter=Q(state__group="completed")),
+                backlog_work_items=Count("id", filter=Q(state__group="backlog")),
+                un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
+                started_work_items=Count("id", filter=Q(state__group="started")),
+            )
+            .values(
+                "project_id",
+                "project__name",
+                "cancelled_work_items",
+                "completed_work_items",
+                "backlog_work_items",
+                "un_started_work_items",
+                "started_work_items",
+            )
+            .order_by("project_id")
+        )
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+        return (
+            base_queryset.annotate(display_name=F("assignees__display_name"))
+            .annotate(assignee_id=F("assignees__id"))
+            .annotate(avatar=F("assignees__avatar"))
+            .annotate(
+                avatar_url=Case(
+                    # If `avatar_asset` exists, use it to generate the asset URL
+                    When(
+                        assignees__avatar_asset__isnull=False,
+                        then=Concat(
+                            Value("/api/assets/v2/static/"),
+                            "assignees__avatar_asset",  # Assuming avatar_asset has an id or relevant field
+                            Value("/"),
+                        ),
+                    ),
+                    # If `avatar_asset` is None, fall back to using `avatar` field directly
+                    When(
+                        assignees__avatar_asset__isnull=True, then="assignees__avatar"
+                    ),
+                    default=Value(None),
+                    output_field=models.CharField(),
+                )
+            )
+            .annotate(
+                cancelled_work_items=Count(
+                    "id", filter=Q(state__group="cancelled"), distinct=True
+                ),
+                completed_work_items=Count(
+                    "id", filter=Q(state__group="completed"), distinct=True
+                ),
+                backlog_work_items=Count(
+                    "id", filter=Q(state__group="backlog"), distinct=True
+                ),
+                un_started_work_items=Count(
+                    "id", filter=Q(state__group="unstarted"), distinct=True
+                ),
+                started_work_items=Count(
+                    "id", filter=Q(state__group="started"), distinct=True
+                ),
+            )
+            .values(
+                "display_name",
+                "assignee_id",
+                "avatar_url",
+                "cancelled_work_items",
+                "completed_work_items",
+                "backlog_work_items",
+                "un_started_work_items",
+                "started_work_items",
+            )
+            .order_by("display_name")
+        )
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="chart")
+        type = request.GET.get("type", "work-items")
+
+        if type == "work-items":
+            # Optionally accept cycle_id or module_id as query params
+            cycle_id = request.GET.get("cycle_id", None)
+            module_id = request.GET.get("module_id", None)
+            return Response(
+                self.get_work_items_stats(
+                    project_id=project_id, cycle_id=cycle_id, module_id=module_id
+                ),
+                status=status.HTTP_200_OK,
+            )
+
+        return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
+
+
+class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def work_item_completion_chart(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Any]:
+        # Get the base queryset
+        queryset = (
+            Issue.issue_objects.filter(**self.filters["base_filters"])
+            .select_related("workspace", "state", "parent")
+            .prefetch_related(
+                "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
+            )
+        )
+
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            cycle = Cycle.objects.filter(id=cycle_id).first()
+            if cycle and cycle.start_date:
+                start_date = cycle.start_date.date()
+                end_date = cycle.end_date.date()
+            else:
+                return {"data": [], "schema": {}}
+            queryset = cycle_issues
+
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            module = Module.objects.filter(id=module_id).first()
+            if module and module.start_date:
+                start_date = module.start_date
+                end_date = module.target_date
+            else:
+                return {"data": [], "schema": {}}
+            queryset = module_issues
+
+        else:
+            project = Project.objects.filter(id=project_id).first()
+            if project.created_at:
+                start_date = project.created_at.date().replace(day=1)
+            else:
+                return {"data": [], "schema": {}}
+
path: apiserver/plane/app/views/analytic/project_analytics.py
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Project creation date fallback missing end_date initialization**

In the "else" branch for project charts, the code initializes `start_date` but doesn't initialize `end_date`, which is needed in the data generation loop later.


```diff
        else:
            project = Project.objects.filter(id=project_id).first()
            if project.created_at:
                start_date = project.created_at.date().replace(day=1)
+                end_date = timezone.now().date()
            else:
                return {"data": [], "schema": {}}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
        else:
            project = Project.objects.filter(id=project_id).first()
            if project.created_at:
                start_date = project.created_at.date().replace(day=1)
                end_date = timezone.now().date()
            else:
                return {"data": [], "schema": {}}
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apiserver/plane/app/views/analytic/project_analytics.py around lines 269 to
275, the else branch sets start_date from project.created_at but does not
initialize end_date, which is required later in the data generation loop. Fix
this by initializing end_date appropriately, for example, setting it to the
current date or another logical endpoint, ensuring both start_date and end_date
are defined before the loop.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T11:29:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,435 @@
+from rest_framework.response import Response
+from rest_framework import status
+from typing import Dict, Any
+from django.db.models import QuerySet, Q, Count
+from django.http import HttpRequest
+from django.db.models.functions import TruncMonth
+from django.utils import timezone
+from datetime import timedelta
+from plane.app.views.base import BaseAPIView
+from plane.app.permissions import ROLE, allow_permission
+from plane.db.models import (
+    Project,
+    Issue,
+    Cycle,
+    Module,
+    CycleIssue,
+    ModuleIssue,
+)
+from django.db import models
+from django.db.models import F, Case, When, Value
+from django.db.models.functions import Concat
+from plane.utils.build_chart import build_analytics_chart
+from plane.utils.date_utils import (
+    get_analytics_filters,
+)
+
+
+class ProjectAdvanceAnalyticsBaseView(BaseAPIView):
+    def initialize_workspace(self, slug: str, type: str) -> None:
+        self._workspace_slug = slug
+        self.filters = get_analytics_filters(
+            slug=slug,
+            type=type,
+            user=self.request.user,
+            date_filter=self.request.GET.get("date_filter", None),
+            project_ids=self.request.GET.get("project_ids", None),
+        )
+
+
+class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
+        def get_filtered_count() -> int:
+            if self.filters["analytics_date_range"]:
+                return queryset.filter(
+                    created_at__gte=self.filters["analytics_date_range"]["current"][
+                        "gte"
+                    ],
+                    created_at__lte=self.filters["analytics_date_range"]["current"][
+                        "lte"
+                    ],
+                ).count()
+            return queryset.count()
+
+        return {
+            "count": get_filtered_count(),
+        }
+
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        """
+        Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
+        """
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+
+        return {
+            "total_work_items": self.get_filtered_counts(base_queryset),
+            "started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="started")
+            ),
+            "backlog_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="backlog")
+            ),
+            "un_started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="unstarted")
+            ),
+            "completed_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="completed")
+            ),
+        }
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="analytics")
+
+        # Optionally accept cycle_id or module_id as query params
+        cycle_id = request.GET.get("cycle_id", None)
+        module_id = request.GET.get("module_id", None)
+        return Response(
+            self.get_work_items_stats(
+                cycle_id=cycle_id, module_id=module_id, project_id=project_id
+            ),
+            status=status.HTTP_200_OK,
+        )
+
+
+class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_project_issues_stats(self) -> QuerySet:
+        # Get the base queryset with workspace and project filters
+        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
+
+        # Apply date range filter if available
+        if self.filters["chart_period_range"]:
+            start_date, end_date = self.filters["chart_period_range"]
+            base_queryset = base_queryset.filter(
+                created_at__date__gte=start_date, created_at__date__lte=end_date
+            )
+
+        return (
+            base_queryset.annotate(
+                cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
+                completed_work_items=Count("id", filter=Q(state__group="completed")),
+                backlog_work_items=Count("id", filter=Q(state__group="backlog")),
+                un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
+                started_work_items=Count("id", filter=Q(state__group="started")),
+            )
+            .values(
+                "project_id",
+                "project__name",
+                "cancelled_work_items",
+                "completed_work_items",
+                "backlog_work_items",
+                "un_started_work_items",
+                "started_work_items",
+            )
+            .order_by("project_id")
+        )
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+        return (
+            base_queryset.annotate(display_name=F("assignees__display_name"))
+            .annotate(assignee_id=F("assignees__id"))
+            .annotate(avatar=F("assignees__avatar"))
+            .annotate(
+                avatar_url=Case(
+                    # If `avatar_asset` exists, use it to generate the asset URL
+                    When(
+                        assignees__avatar_asset__isnull=False,
+                        then=Concat(
+                            Value("/api/assets/v2/static/"),
+                            "assignees__avatar_asset",  # Assuming avatar_asset has an id or relevant field
+                            Value("/"),
+                        ),
+                    ),
+                    # If `avatar_asset` is None, fall back to using `avatar` field directly
+                    When(
+                        assignees__avatar_asset__isnull=True, then="assignees__avatar"
+                    ),
+                    default=Value(None),
+                    output_field=models.CharField(),
+                )
+            )
+            .annotate(
+                cancelled_work_items=Count(
+                    "id", filter=Q(state__group="cancelled"), distinct=True
+                ),
+                completed_work_items=Count(
+                    "id", filter=Q(state__group="completed"), distinct=True
+                ),
+                backlog_work_items=Count(
+                    "id", filter=Q(state__group="backlog"), distinct=True
+                ),
+                un_started_work_items=Count(
+                    "id", filter=Q(state__group="unstarted"), distinct=True
+                ),
+                started_work_items=Count(
+                    "id", filter=Q(state__group="started"), distinct=True
+                ),
+            )
+            .values(
+                "display_name",
+                "assignee_id",
+                "avatar_url",
+                "cancelled_work_items",
+                "completed_work_items",
+                "backlog_work_items",
+                "un_started_work_items",
+                "started_work_items",
+            )
+            .order_by("display_name")
+        )
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="chart")
+        type = request.GET.get("type", "work-items")
+
+        if type == "work-items":
+            # Optionally accept cycle_id or module_id as query params
+            cycle_id = request.GET.get("cycle_id", None)
+            module_id = request.GET.get("module_id", None)
+            return Response(
+                self.get_work_items_stats(
+                    project_id=project_id, cycle_id=cycle_id, module_id=module_id
+                ),
+                status=status.HTTP_200_OK,
+            )
+
+        return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
+
+
+class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def work_item_completion_chart(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Any]:
+        # Get the base queryset
+        queryset = (
+            Issue.issue_objects.filter(**self.filters["base_filters"])
+            .select_related("workspace", "state", "parent")
+            .prefetch_related(
+                "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
+            )
+        )
+
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            cycle = Cycle.objects.filter(id=cycle_id).first()
+            if cycle and cycle.start_date:
+                start_date = cycle.start_date.date()
+                end_date = cycle.end_date.date()
+            else:
+                return {"data": [], "schema": {}}
+            queryset = cycle_issues
+
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            module = Module.objects.filter(id=module_id).first()
+            if module and module.start_date:
+                start_date = module.start_date
+                end_date = module.target_date
+            else:
+                return {"data": [], "schema": {}}
+            queryset = module_issues
+
+        else:
+            project = Project.objects.filter(id=project_id).first()
+            if project.created_at:
+                start_date = project.created_at.date().replace(day=1)
+            else:
+                return {"data": [], "schema": {}}
+
+        if cycle_id or module_id:
+            # Get daily stats with optimized query
+            daily_stats = (
+                queryset.values("created_at__date")
+                .annotate(
+                    created_count=Count("id"),
+                    completed_count=Count(
+                        "id", filter=Q(issue__state__group="completed")
+                    ),
+                )
+                .order_by("created_at__date")
+            )
+
+            # Create a dictionary of existing stats with summed counts
+            stats_dict = {
+                stat["created_at__date"].strftime("%Y-%m-%d"): {
+                    "created_count": stat["created_count"],
+                    "completed_count": stat["completed_count"],
+                }
+                for stat in daily_stats
+            }
+
+            # Generate data for all days in the range
+            data = []
+            current_date = start_date
+            while current_date <= end_date:
+                date_str = current_date.strftime("%Y-%m-%d")
+                stats = stats_dict.get(
+                    date_str, {"created_count": 0, "completed_count": 0}
+                )
+                data.append(
+                    {
+                        "key": date_str,
+                        "name": date_str,
+                        "count": stats["created_count"] + stats["completed_count"],
+                        "completed_issues": stats["completed_count"],
+                        "created_issues": stats["created_count"],
+                    }
+                )
+                current_date += timedelta(days=1)
+        else:
+            # Apply date range filter if available
+            if self.filters["chart_period_range"]:
+                start_date, end_date = self.filters["chart_period_range"]
+                queryset = queryset.filter(
+                    created_at__date__gte=start_date, created_at__date__lte=end_date
+                )
+
+            # Annotate by month and count
+            monthly_stats = (
+                queryset.annotate(month=TruncMonth("created_at"))
+                .values("month")
+                .annotate(
+                    created_count=Count("id"),
+                    completed_count=Count("id", filter=Q(state__group="completed")),
+                )
+                .order_by("month")
+            )
+
+            # Create dictionary of month -> counts
+            stats_dict = {
+                stat["month"].strftime("%Y-%m-%d"): {
+                    "created_count": stat["created_count"],
+                    "completed_count": stat["completed_count"],
+                }
+                for stat in monthly_stats
+            }
+
+            # Generate monthly data (ensure months with 0 count are included)
+            data = []
+            # include the current date at the end
+            end_date = timezone.now().date()
+            last_month = end_date.replace(day=1)
+            current_month = start_date
+
+            while current_month <= last_month:
+                date_str = current_month.strftime("%Y-%m-%d")
+                stats = stats_dict.get(
+                    date_str, {"created_count": 0, "completed_count": 0}
+                )
+                data.append(
+                    {
+                        "key": date_str,
+                        "name": date_str,
+                        "count": stats["created_count"],
+                        "completed_issues": stats["completed_count"],
+                        "created_issues": stats["created_count"],
+                    }
+                )
+                # Move to next month
+                if current_month.month == 12:
+                    current_month = current_month.replace(
+                        year=current_month.year + 1, month=1
+                    )
+                else:
+                    current_month = current_month.replace(month=current_month.month + 1)
+
+        schema = {
+            "completed_issues": "completed_issues",
+            "created_issues": "created_issues",
+        }
+
+        return {"data": data, "schema": schema}
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="chart")
+        type = request.GET.get("type", "projects")
+        group_by = request.GET.get("group_by", None)
+        x_axis = request.GET.get("x_axis", "PRIORITY")
+        cycle_id = request.GET.get("cycle_id", None)
+        module_id = request.GET.get("module_id", None)
+
+        if type == "custom-work-items":
+            queryset = (
+                Issue.issue_objects.filter(**self.filters["base_filters"])
+                .select_related("workspace", "state", "parent")
+                .prefetch_related(
+                    "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
+                )
+            )
+
+            # Apply cycle/module filters if present
+            if cycle_id is not None:
+                cycle_issues = CycleIssue.objects.filter(
+                    **self.filters["base_filters"], cycle_id=cycle_id
+                ).values_list("issue_id", flat=True)
+                queryset = queryset.filter(id__in=cycle_issues)
+
+            elif module_id is not None:
+                module_issues = ModuleIssue.objects.filter(
+                    **self.filters["base_filters"], module_id=module_id
+                ).values_list("issue_id", flat=True)
+                queryset = queryset.filter(id__in=module_issues)
+
+            # Apply date range filter if available
+            if self.filters["chart_period_range"]:
+                start_date, end_date = self.filters["chart_period_range"]
+                queryset = queryset.filter(
+                    created_at__date__gte=start_date, created_at__date__lte=end_date
+                )
+
+            return Response(
+                build_analytics_chart(queryset, x_axis, group_by),
+                status=status.HTTP_200_OK,
+            )
+
+        elif type == "work-items":
+            # Optionally accept cycle_id or module_id as query params
+            cycle_id = request.GET.get("cycle_id", None)
+            module_id = request.GET.get("module_id", None)
+
+            return Response(
+                self.work_item_completion_chart(
+                    project_id=project_id, cycle_id=cycle_id, module_id=module_id
+                ),
+                status=status.HTTP_200_OK,
+            )
+
+        return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
path: apiserver/plane/app/views/analytic/project_analytics.py
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Validate project ownership in GET method**

The endpoint should verify that the user has access to the requested project before returning analytics data. While `@allow_permission` checks roles, it doesn't verify project access.


```diff
    @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
        self.initialize_workspace(slug, type="chart")
+        # Verify project access
+        project = Project.objects.filter(
+            workspace__slug=slug, id=project_id
+        ).first()
+        if not project:
+            return Response(
+                {"error": "Project not found"}, 
+                status=status.HTTP_404_NOT_FOUND
+            )
        
        type = request.GET.get("type", "projects")
        # Rest of the method...
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
    @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
        self.initialize_workspace(slug, type="chart")
+        # Verify project access
+        project = Project.objects.filter(
+            workspace__slug=slug, id=project_id
+        ).first()
+        if not project:
+            return Response(
+                {"error": "Project not found"},
+                status=status.HTTP_404_NOT_FOUND
+            )

        type = request.GET.get("type", "projects")
        group_by = request.GET.get("group_by", None)
        x_axis = request.GET.get("x_axis", "PRIORITY")
        cycle_id = request.GET.get("cycle_id", None)
        module_id = request.GET.get("module_id", None)

        if type == "custom-work-items":
            queryset = (
                Issue.issue_objects.filter(**self.filters["base_filters"])
                .select_related("workspace", "state", "parent")
                .prefetch_related(
                    "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
                )
            )

            # Apply cycle/module filters if present
            if cycle_id is not None:
                cycle_issues = CycleIssue.objects.filter(
                    **self.filters["base_filters"], cycle_id=cycle_id
                ).values_list("issue_id", flat=True)
                queryset = queryset.filter(id__in=cycle_issues)

            elif module_id is not None:
                module_issues = ModuleIssue.objects.filter(
                    **self.filters["base_filters"], module_id=module_id
                ).values_list("issue_id", flat=True)
                queryset = queryset.filter(id__in=module_issues)

            # Apply date range filter if available
            if self.filters["chart_period_range"]:
                start_date, end_date = self.filters["chart_period_range"]
                queryset = queryset.filter(
                    created_at__date__gte=start_date, created_at__date__lte=end_date
                )

            return Response(
                build_analytics_chart(queryset, x_axis, group_by),
                status=status.HTTP_200_OK,
            )

        elif type == "work-items":
            # Optionally accept cycle_id or module_id as query params
            cycle_id = request.GET.get("cycle_id", None)
            module_id = request.GET.get("module_id", None)

            return Response(
                self.work_item_completion_chart(
                    project_id=project_id, cycle_id=cycle_id, module_id=module_id
                ),
                status=status.HTTP_200_OK,
            )

        return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apiserver/plane/app/views/analytic/project_analytics.py between lines 380 and
435, the GET method lacks validation to confirm that the requesting user has
access rights to the specified project_id. To fix this, add a check early in the
GET method to verify project ownership or membership for the user making the
request. If the user does not have access, return an appropriate HTTP 403
Forbidden response before proceeding with analytics data retrieval.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T11:29:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,435 @@
+from rest_framework.response import Response
+from rest_framework import status
+from typing import Dict, Any
+from django.db.models import QuerySet, Q, Count
+from django.http import HttpRequest
+from django.db.models.functions import TruncMonth
+from django.utils import timezone
+from datetime import timedelta
+from plane.app.views.base import BaseAPIView
+from plane.app.permissions import ROLE, allow_permission
+from plane.db.models import (
+    Project,
+    Issue,
+    Cycle,
+    Module,
+    CycleIssue,
+    ModuleIssue,
+)
+from django.db import models
+from django.db.models import F, Case, When, Value
+from django.db.models.functions import Concat
+from plane.utils.build_chart import build_analytics_chart
+from plane.utils.date_utils import (
+    get_analytics_filters,
+)
+
+
+class ProjectAdvanceAnalyticsBaseView(BaseAPIView):
+    def initialize_workspace(self, slug: str, type: str) -> None:
+        self._workspace_slug = slug
+        self.filters = get_analytics_filters(
+            slug=slug,
+            type=type,
+            user=self.request.user,
+            date_filter=self.request.GET.get("date_filter", None),
+            project_ids=self.request.GET.get("project_ids", None),
+        )
+
+
+class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
+        def get_filtered_count() -> int:
+            if self.filters["analytics_date_range"]:
+                return queryset.filter(
+                    created_at__gte=self.filters["analytics_date_range"]["current"][
+                        "gte"
+                    ],
+                    created_at__lte=self.filters["analytics_date_range"]["current"][
+                        "lte"
+                    ],
+                ).count()
+            return queryset.count()
+
+        return {
+            "count": get_filtered_count(),
+        }
+
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        """
+        Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
+        """
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+
+        return {
+            "total_work_items": self.get_filtered_counts(base_queryset),
+            "started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="started")
+            ),
+            "backlog_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="backlog")
+            ),
+            "un_started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="unstarted")
+            ),
+            "completed_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="completed")
+            ),
+        }
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="analytics")
+
+        # Optionally accept cycle_id or module_id as query params
+        cycle_id = request.GET.get("cycle_id", None)
+        module_id = request.GET.get("module_id", None)
+        return Response(
+            self.get_work_items_stats(
+                cycle_id=cycle_id, module_id=module_id, project_id=project_id
+            ),
+            status=status.HTTP_200_OK,
+        )
+
+
+class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_project_issues_stats(self) -> QuerySet:
+        # Get the base queryset with workspace and project filters
+        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
+
+        # Apply date range filter if available
+        if self.filters["chart_period_range"]:
+            start_date, end_date = self.filters["chart_period_range"]
+            base_queryset = base_queryset.filter(
+                created_at__date__gte=start_date, created_at__date__lte=end_date
+            )
+
+        return (
+            base_queryset.annotate(
+                cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
+                completed_work_items=Count("id", filter=Q(state__group="completed")),
+                backlog_work_items=Count("id", filter=Q(state__group="backlog")),
+                un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
+                started_work_items=Count("id", filter=Q(state__group="started")),
+            )
+            .values(
+                "project_id",
+                "project__name",
+                "cancelled_work_items",
+                "completed_work_items",
+                "backlog_work_items",
+                "un_started_work_items",
+                "started_work_items",
+            )
+            .order_by("project_id")
+        )
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+        return (
+            base_queryset.annotate(display_name=F("assignees__display_name"))
+            .annotate(assignee_id=F("assignees__id"))
+            .annotate(avatar=F("assignees__avatar"))
+            .annotate(
+                avatar_url=Case(
+                    # If `avatar_asset` exists, use it to generate the asset URL
+                    When(
+                        assignees__avatar_asset__isnull=False,
+                        then=Concat(
+                            Value("/api/assets/v2/static/"),
+                            "assignees__avatar_asset",  # Assuming avatar_asset has an id or relevant field
+                            Value("/"),
+                        ),
+                    ),
+                    # If `avatar_asset` is None, fall back to using `avatar` field directly
+                    When(
+                        assignees__avatar_asset__isnull=True, then="assignees__avatar"
+                    ),
+                    default=Value(None),
+                    output_field=models.CharField(),
+                )
+            )
+            .annotate(
+                cancelled_work_items=Count(
+                    "id", filter=Q(state__group="cancelled"), distinct=True
+                ),
+                completed_work_items=Count(
+                    "id", filter=Q(state__group="completed"), distinct=True
+                ),
+                backlog_work_items=Count(
+                    "id", filter=Q(state__group="backlog"), distinct=True
+                ),
+                un_started_work_items=Count(
+                    "id", filter=Q(state__group="unstarted"), distinct=True
+                ),
+                started_work_items=Count(
+                    "id", filter=Q(state__group="started"), distinct=True
+                ),
+            )
+            .values(
+                "display_name",
+                "assignee_id",
+                "avatar_url",
+                "cancelled_work_items",
+                "completed_work_items",
+                "backlog_work_items",
+                "un_started_work_items",
+                "started_work_items",
+            )
+            .order_by("display_name")
+        )
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="chart")
+        type = request.GET.get("type", "work-items")
+
+        if type == "work-items":
+            # Optionally accept cycle_id or module_id as query params
+            cycle_id = request.GET.get("cycle_id", None)
+            module_id = request.GET.get("module_id", None)
+            return Response(
+                self.get_work_items_stats(
+                    project_id=project_id, cycle_id=cycle_id, module_id=module_id
+                ),
+                status=status.HTTP_200_OK,
+            )
+
+        return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
+
+
+class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def work_item_completion_chart(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Any]:
+        # Get the base queryset
+        queryset = (
+            Issue.issue_objects.filter(**self.filters["base_filters"])
+            .select_related("workspace", "state", "parent")
+            .prefetch_related(
+                "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
+            )
+        )
+
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            cycle = Cycle.objects.filter(id=cycle_id).first()
+            if cycle and cycle.start_date:
+                start_date = cycle.start_date.date()
+                end_date = cycle.end_date.date()
+            else:
+                return {"data": [], "schema": {}}
+            queryset = cycle_issues
+
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            module = Module.objects.filter(id=module_id).first()
+            if module and module.start_date:
+                start_date = module.start_date
+                end_date = module.target_date
+            else:
+                return {"data": [], "schema": {}}
+            queryset = module_issues
+
+        else:
+            project = Project.objects.filter(id=project_id).first()
+            if project.created_at:
+                start_date = project.created_at.date().replace(day=1)
+            else:
+                return {"data": [], "schema": {}}
+
+        if cycle_id or module_id:
+            # Get daily stats with optimized query
+            daily_stats = (
+                queryset.values("created_at__date")
+                .annotate(
+                    created_count=Count("id"),
+                    completed_count=Count(
+                        "id", filter=Q(issue__state__group="completed")
+                    ),
+                )
+                .order_by("created_at__date")
+            )
+
+            # Create a dictionary of existing stats with summed counts
+            stats_dict = {
+                stat["created_at__date"].strftime("%Y-%m-%d"): {
+                    "created_count": stat["created_count"],
+                    "completed_count": stat["completed_count"],
+                }
+                for stat in daily_stats
+            }
+
+            # Generate data for all days in the range
+            data = []
+            current_date = start_date
+            while current_date <= end_date:
+                date_str = current_date.strftime("%Y-%m-%d")
+                stats = stats_dict.get(
+                    date_str, {"created_count": 0, "completed_count": 0}
+                )
+                data.append(
+                    {
+                        "key": date_str,
+                        "name": date_str,
+                        "count": stats["created_count"] + stats["completed_count"],
+                        "completed_issues": stats["completed_count"],
+                        "created_issues": stats["created_count"],
+                    }
+                )
+                current_date += timedelta(days=1)
+        else:
+            # Apply date range filter if available
+            if self.filters["chart_period_range"]:
+                start_date, end_date = self.filters["chart_period_range"]
+                queryset = queryset.filter(
+                    created_at__date__gte=start_date, created_at__date__lte=end_date
+                )
+
+            # Annotate by month and count
+            monthly_stats = (
+                queryset.annotate(month=TruncMonth("created_at"))
+                .values("month")
+                .annotate(
+                    created_count=Count("id"),
+                    completed_count=Count("id", filter=Q(state__group="completed")),
+                )
+                .order_by("month")
+            )
+
+            # Create dictionary of month -> counts
+            stats_dict = {
+                stat["month"].strftime("%Y-%m-%d"): {
+                    "created_count": stat["created_count"],
+                    "completed_count": stat["completed_count"],
+                }
+                for stat in monthly_stats
+            }
+
+            # Generate monthly data (ensure months with 0 count are included)
+            data = []
+            # include the current date at the end
+            end_date = timezone.now().date()
+            last_month = end_date.replace(day=1)
+            current_month = start_date
+
+            while current_month <= last_month:
+                date_str = current_month.strftime("%Y-%m-%d")
+                stats = stats_dict.get(
+                    date_str, {"created_count": 0, "completed_count": 0}
+                )
+                data.append(
+                    {
+                        "key": date_str,
+                        "name": date_str,
+                        "count": stats["created_count"],
+                        "completed_issues": stats["completed_count"],
+                        "created_issues": stats["created_count"],
+                    }
+                )
+                # Move to next month
+                if current_month.month == 12:
+                    current_month = current_month.replace(
+                        year=current_month.year + 1, month=1
+                    )
+                else:
+                    current_month = current_month.replace(month=current_month.month + 1)
+
+        schema = {
+            "completed_issues": "completed_issues",
+            "created_issues": "created_issues",
+        }
+
+        return {"data": data, "schema": schema}
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="chart")
+        type = request.GET.get("type", "projects")
+        group_by = request.GET.get("group_by", None)
+        x_axis = request.GET.get("x_axis", "PRIORITY")
+        cycle_id = request.GET.get("cycle_id", None)
+        module_id = request.GET.get("module_id", None)
+
+        if type == "custom-work-items":
+            queryset = (
+                Issue.issue_objects.filter(**self.filters["base_filters"])
+                .select_related("workspace", "state", "parent")
+                .prefetch_related(
+                    "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
+                )
+            )
path: apiserver/plane/app/views/analytic/project_analytics.py
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Add project_id filter to custom-work-items queryset**

Similar to other sections, the queryset for custom-work-items isn't filtered by project_id, potentially showing workspace-wide data.


```diff
        if type == "custom-work-items":
            queryset = (
                Issue.issue_objects.filter(**self.filters["base_filters"])
+                .filter(project_id=project_id)
                .select_related("workspace", "state", "parent")
                .prefetch_related(
                    "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
                )
            )
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
        if type == "custom-work-items":
            queryset = (
                Issue.issue_objects.filter(**self.filters["base_filters"])
                .filter(project_id=project_id)
                .select_related("workspace", "state", "parent")
                .prefetch_related(
                    "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
                )
            )
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apiserver/plane/app/views/analytic/project_analytics.py around lines 390 to
396, the queryset for custom-work-items lacks a filter for project_id, which may
cause it to include data from the entire workspace instead of the specific
project. Add a filter condition for project_id to the queryset using the
appropriate project_id value from the context or filters to restrict the data to
the current project only.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T11:29:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,435 @@
+from rest_framework.response import Response
+from rest_framework import status
+from typing import Dict, Any
+from django.db.models import QuerySet, Q, Count
+from django.http import HttpRequest
+from django.db.models.functions import TruncMonth
+from django.utils import timezone
+from datetime import timedelta
+from plane.app.views.base import BaseAPIView
+from plane.app.permissions import ROLE, allow_permission
+from plane.db.models import (
+    Project,
+    Issue,
+    Cycle,
+    Module,
+    CycleIssue,
+    ModuleIssue,
+)
+from django.db import models
+from django.db.models import F, Case, When, Value
+from django.db.models.functions import Concat
+from plane.utils.build_chart import build_analytics_chart
+from plane.utils.date_utils import (
+    get_analytics_filters,
+)
+
+
+class ProjectAdvanceAnalyticsBaseView(BaseAPIView):
+    def initialize_workspace(self, slug: str, type: str) -> None:
+        self._workspace_slug = slug
+        self.filters = get_analytics_filters(
+            slug=slug,
+            type=type,
+            user=self.request.user,
+            date_filter=self.request.GET.get("date_filter", None),
+            project_ids=self.request.GET.get("project_ids", None),
+        )
+
+
+class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
+        def get_filtered_count() -> int:
+            if self.filters["analytics_date_range"]:
+                return queryset.filter(
+                    created_at__gte=self.filters["analytics_date_range"]["current"][
+                        "gte"
+                    ],
+                    created_at__lte=self.filters["analytics_date_range"]["current"][
+                        "lte"
+                    ],
+                ).count()
+            return queryset.count()
+
+        return {
+            "count": get_filtered_count(),
+        }
+
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        """
+        Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
+        """
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+
+        return {
+            "total_work_items": self.get_filtered_counts(base_queryset),
+            "started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="started")
+            ),
+            "backlog_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="backlog")
+            ),
+            "un_started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="unstarted")
+            ),
+            "completed_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="completed")
+            ),
+        }
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="analytics")
+
+        # Optionally accept cycle_id or module_id as query params
+        cycle_id = request.GET.get("cycle_id", None)
+        module_id = request.GET.get("module_id", None)
+        return Response(
+            self.get_work_items_stats(
+                cycle_id=cycle_id, module_id=module_id, project_id=project_id
+            ),
+            status=status.HTTP_200_OK,
+        )
+
+
+class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_project_issues_stats(self) -> QuerySet:
+        # Get the base queryset with workspace and project filters
+        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
+
+        # Apply date range filter if available
+        if self.filters["chart_period_range"]:
+            start_date, end_date = self.filters["chart_period_range"]
+            base_queryset = base_queryset.filter(
+                created_at__date__gte=start_date, created_at__date__lte=end_date
+            )
+
+        return (
+            base_queryset.annotate(
+                cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
+                completed_work_items=Count("id", filter=Q(state__group="completed")),
+                backlog_work_items=Count("id", filter=Q(state__group="backlog")),
+                un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
+                started_work_items=Count("id", filter=Q(state__group="started")),
+            )
+            .values(
+                "project_id",
+                "project__name",
+                "cancelled_work_items",
+                "completed_work_items",
+                "backlog_work_items",
+                "un_started_work_items",
+                "started_work_items",
+            )
+            .order_by("project_id")
+        )
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+        return (
+            base_queryset.annotate(display_name=F("assignees__display_name"))
+            .annotate(assignee_id=F("assignees__id"))
+            .annotate(avatar=F("assignees__avatar"))
+            .annotate(
+                avatar_url=Case(
+                    # If `avatar_asset` exists, use it to generate the asset URL
+                    When(
+                        assignees__avatar_asset__isnull=False,
+                        then=Concat(
+                            Value("/api/assets/v2/static/"),
+                            "assignees__avatar_asset",  # Assuming avatar_asset has an id or relevant field
+                            Value("/"),
+                        ),
+                    ),
+                    # If `avatar_asset` is None, fall back to using `avatar` field directly
+                    When(
+                        assignees__avatar_asset__isnull=True, then="assignees__avatar"
+                    ),
+                    default=Value(None),
+                    output_field=models.CharField(),
+                )
+            )
+            .annotate(
+                cancelled_work_items=Count(
+                    "id", filter=Q(state__group="cancelled"), distinct=True
+                ),
+                completed_work_items=Count(
+                    "id", filter=Q(state__group="completed"), distinct=True
+                ),
+                backlog_work_items=Count(
+                    "id", filter=Q(state__group="backlog"), distinct=True
+                ),
+                un_started_work_items=Count(
+                    "id", filter=Q(state__group="unstarted"), distinct=True
+                ),
+                started_work_items=Count(
+                    "id", filter=Q(state__group="started"), distinct=True
+                ),
+            )
+            .values(
+                "display_name",
+                "assignee_id",
+                "avatar_url",
+                "cancelled_work_items",
+                "completed_work_items",
+                "backlog_work_items",
+                "un_started_work_items",
+                "started_work_items",
+            )
+            .order_by("display_name")
+        )
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="chart")
+        type = request.GET.get("type", "work-items")
+
+        if type == "work-items":
+            # Optionally accept cycle_id or module_id as query params
+            cycle_id = request.GET.get("cycle_id", None)
+            module_id = request.GET.get("module_id", None)
+            return Response(
+                self.get_work_items_stats(
+                    project_id=project_id, cycle_id=cycle_id, module_id=module_id
+                ),
+                status=status.HTTP_200_OK,
+            )
+
+        return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
+
+
+class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def work_item_completion_chart(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Any]:
+        # Get the base queryset
+        queryset = (
+            Issue.issue_objects.filter(**self.filters["base_filters"])
+            .select_related("workspace", "state", "parent")
+            .prefetch_related(
+                "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
+            )
+        )
+
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            cycle = Cycle.objects.filter(id=cycle_id).first()
+            if cycle and cycle.start_date:
+                start_date = cycle.start_date.date()
+                end_date = cycle.end_date.date()
+            else:
+                return {"data": [], "schema": {}}
+            queryset = cycle_issues
+
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            module = Module.objects.filter(id=module_id).first()
+            if module and module.start_date:
+                start_date = module.start_date
+                end_date = module.target_date
+            else:
+                return {"data": [], "schema": {}}
+            queryset = module_issues
+
+        else:
+            project = Project.objects.filter(id=project_id).first()
+            if project.created_at:
+                start_date = project.created_at.date().replace(day=1)
+            else:
+                return {"data": [], "schema": {}}
+
+        if cycle_id or module_id:
+            # Get daily stats with optimized query
+            daily_stats = (
+                queryset.values("created_at__date")
+                .annotate(
+                    created_count=Count("id"),
+                    completed_count=Count(
+                        "id", filter=Q(issue__state__group="completed")
+                    ),
+                )
+                .order_by("created_at__date")
+            )
+
+            # Create a dictionary of existing stats with summed counts
+            stats_dict = {
+                stat["created_at__date"].strftime("%Y-%m-%d"): {
+                    "created_count": stat["created_count"],
+                    "completed_count": stat["completed_count"],
+                }
+                for stat in daily_stats
+            }
+
+            # Generate data for all days in the range
+            data = []
+            current_date = start_date
+            while current_date <= end_date:
+                date_str = current_date.strftime("%Y-%m-%d")
+                stats = stats_dict.get(
+                    date_str, {"created_count": 0, "completed_count": 0}
+                )
+                data.append(
+                    {
+                        "key": date_str,
+                        "name": date_str,
+                        "count": stats["created_count"] + stats["completed_count"],
+                        "completed_issues": stats["completed_count"],
+                        "created_issues": stats["created_count"],
+                    }
+                )
+                current_date += timedelta(days=1)
+        else:
+            # Apply date range filter if available
+            if self.filters["chart_period_range"]:
+                start_date, end_date = self.filters["chart_period_range"]
+                queryset = queryset.filter(
+                    created_at__date__gte=start_date, created_at__date__lte=end_date
+                )
+
+            # Annotate by month and count
+            monthly_stats = (
+                queryset.annotate(month=TruncMonth("created_at"))
+                .values("month")
+                .annotate(
+                    created_count=Count("id"),
+                    completed_count=Count("id", filter=Q(state__group="completed")),
+                )
+                .order_by("month")
+            )
+
+            # Create dictionary of month -> counts
+            stats_dict = {
+                stat["month"].strftime("%Y-%m-%d"): {
+                    "created_count": stat["created_count"],
+                    "completed_count": stat["completed_count"],
+                }
+                for stat in monthly_stats
path: apiserver/plane/app/views/analytic/project_analytics.py
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Count schema inconsistency in monthly chart data**

In the `stats_dict` creation, the "count" field in the data (line 360) is populated with `stats["created_count"]`, but this should likely include both created and completed counts like in the daily stats.


```diff
                data.append(
                    {
                        "key": date_str,
                        "name": date_str,
-                        "count": stats["created_count"],
+                        "count": stats["created_count"] + stats["completed_count"],
                        "completed_issues": stats["completed_count"],
                        "created_issues": stats["created_count"],
                    }
                )
```


> Committable suggestion skipped: line range outside the PR's diff.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apiserver/plane/app/views/analytic/project_analytics.py around lines 338 to
341, the "count" field in the monthly_stats dictionary is currently set to only
the created_count, which is inconsistent with the daily stats that sum created
and completed counts. Update the "count" field to be the sum of both
created_count and completed_count to maintain consistency in the data schema.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T11:29:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,435 @@
+from rest_framework.response import Response
+from rest_framework import status
+from typing import Dict, Any
+from django.db.models import QuerySet, Q, Count
+from django.http import HttpRequest
+from django.db.models.functions import TruncMonth
+from django.utils import timezone
+from datetime import timedelta
+from plane.app.views.base import BaseAPIView
+from plane.app.permissions import ROLE, allow_permission
+from plane.db.models import (
+    Project,
+    Issue,
+    Cycle,
+    Module,
+    CycleIssue,
+    ModuleIssue,
+)
+from django.db import models
+from django.db.models import F, Case, When, Value
+from django.db.models.functions import Concat
+from plane.utils.build_chart import build_analytics_chart
+from plane.utils.date_utils import (
+    get_analytics_filters,
+)
+
+
+class ProjectAdvanceAnalyticsBaseView(BaseAPIView):
+    def initialize_workspace(self, slug: str, type: str) -> None:
+        self._workspace_slug = slug
+        self.filters = get_analytics_filters(
+            slug=slug,
+            type=type,
+            user=self.request.user,
+            date_filter=self.request.GET.get("date_filter", None),
+            project_ids=self.request.GET.get("project_ids", None),
+        )
+
+
+class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
+        def get_filtered_count() -> int:
+            if self.filters["analytics_date_range"]:
+                return queryset.filter(
+                    created_at__gte=self.filters["analytics_date_range"]["current"][
+                        "gte"
+                    ],
+                    created_at__lte=self.filters["analytics_date_range"]["current"][
+                        "lte"
+                    ],
+                ).count()
+            return queryset.count()
+
+        return {
+            "count": get_filtered_count(),
+        }
+
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        """
+        Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
+        """
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+
+        return {
+            "total_work_items": self.get_filtered_counts(base_queryset),
+            "started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="started")
+            ),
+            "backlog_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="backlog")
+            ),
+            "un_started_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="unstarted")
+            ),
+            "completed_work_items": self.get_filtered_counts(
+                base_queryset.filter(state__group="completed")
+            ),
+        }
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="analytics")
+
+        # Optionally accept cycle_id or module_id as query params
+        cycle_id = request.GET.get("cycle_id", None)
+        module_id = request.GET.get("module_id", None)
+        return Response(
+            self.get_work_items_stats(
+                cycle_id=cycle_id, module_id=module_id, project_id=project_id
+            ),
+            status=status.HTTP_200_OK,
+        )
+
+
+class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def get_project_issues_stats(self) -> QuerySet:
+        # Get the base queryset with workspace and project filters
+        base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
+
+        # Apply date range filter if available
+        if self.filters["chart_period_range"]:
+            start_date, end_date = self.filters["chart_period_range"]
+            base_queryset = base_queryset.filter(
+                created_at__date__gte=start_date, created_at__date__lte=end_date
+            )
+
+        return (
+            base_queryset.annotate(
+                cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
+                completed_work_items=Count("id", filter=Q(state__group="completed")),
+                backlog_work_items=Count("id", filter=Q(state__group="backlog")),
+                un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
+                started_work_items=Count("id", filter=Q(state__group="started")),
+            )
+            .values(
+                "project_id",
+                "project__name",
+                "cancelled_work_items",
+                "completed_work_items",
+                "backlog_work_items",
+                "un_started_work_items",
+                "started_work_items",
+            )
+            .order_by("project_id")
+        )
+    def get_work_items_stats(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Dict[str, int]]:
+        base_queryset = None
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            base_queryset = Issue.issue_objects.filter(id__in=module_issues)
+        else:
+            base_queryset = Issue.issue_objects.filter(
+                **self.filters["base_filters"], project_id=project_id
+            )
+        return (
+            base_queryset.annotate(display_name=F("assignees__display_name"))
+            .annotate(assignee_id=F("assignees__id"))
+            .annotate(avatar=F("assignees__avatar"))
+            .annotate(
+                avatar_url=Case(
+                    # If `avatar_asset` exists, use it to generate the asset URL
+                    When(
+                        assignees__avatar_asset__isnull=False,
+                        then=Concat(
+                            Value("/api/assets/v2/static/"),
+                            "assignees__avatar_asset",  # Assuming avatar_asset has an id or relevant field
+                            Value("/"),
+                        ),
+                    ),
+                    # If `avatar_asset` is None, fall back to using `avatar` field directly
+                    When(
+                        assignees__avatar_asset__isnull=True, then="assignees__avatar"
+                    ),
+                    default=Value(None),
+                    output_field=models.CharField(),
+                )
+            )
+            .annotate(
+                cancelled_work_items=Count(
+                    "id", filter=Q(state__group="cancelled"), distinct=True
+                ),
+                completed_work_items=Count(
+                    "id", filter=Q(state__group="completed"), distinct=True
+                ),
+                backlog_work_items=Count(
+                    "id", filter=Q(state__group="backlog"), distinct=True
+                ),
+                un_started_work_items=Count(
+                    "id", filter=Q(state__group="unstarted"), distinct=True
+                ),
+                started_work_items=Count(
+                    "id", filter=Q(state__group="started"), distinct=True
+                ),
+            )
+            .values(
+                "display_name",
+                "assignee_id",
+                "avatar_url",
+                "cancelled_work_items",
+                "completed_work_items",
+                "backlog_work_items",
+                "un_started_work_items",
+                "started_work_items",
+            )
+            .order_by("display_name")
+        )
+
+    @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
+    def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
+        self.initialize_workspace(slug, type="chart")
+        type = request.GET.get("type", "work-items")
+
+        if type == "work-items":
+            # Optionally accept cycle_id or module_id as query params
+            cycle_id = request.GET.get("cycle_id", None)
+            module_id = request.GET.get("module_id", None)
+            return Response(
+                self.get_work_items_stats(
+                    project_id=project_id, cycle_id=cycle_id, module_id=module_id
+                ),
+                status=status.HTTP_200_OK,
+            )
+
+        return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
+
+
+class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
+    def work_item_completion_chart(
+        self, project_id, cycle_id=None, module_id=None
+    ) -> Dict[str, Any]:
+        # Get the base queryset
+        queryset = (
+            Issue.issue_objects.filter(**self.filters["base_filters"])
+            .select_related("workspace", "state", "parent")
+            .prefetch_related(
+                "assignees", "labels", "issue_module__module", "issue_cycle__cycle"
+            )
+        )
+
+        if cycle_id is not None:
+            cycle_issues = CycleIssue.objects.filter(
+                **self.filters["base_filters"], cycle_id=cycle_id
+            ).values_list("issue_id", flat=True)
+            cycle = Cycle.objects.filter(id=cycle_id).first()
+            if cycle and cycle.start_date:
+                start_date = cycle.start_date.date()
+                end_date = cycle.end_date.date()
+            else:
+                return {"data": [], "schema": {}}
+            queryset = cycle_issues
+
+        elif module_id is not None:
+            module_issues = ModuleIssue.objects.filter(
+                **self.filters["base_filters"], module_id=module_id
+            ).values_list("issue_id", flat=True)
+            module = Module.objects.filter(id=module_id).first()
+            if module and module.start_date:
+                start_date = module.start_date
+                end_date = module.target_date
+            else:
+                return {"data": [], "schema": {}}
+            queryset = module_issues
+
+        else:
+            project = Project.objects.filter(id=project_id).first()
+            if project.created_at:
+                start_date = project.created_at.date().replace(day=1)
+            else:
+                return {"data": [], "schema": {}}
+
+        if cycle_id or module_id:
+            # Get daily stats with optimized query
+            daily_stats = (
+                queryset.values("created_at__date")
+                .annotate(
+                    created_count=Count("id"),
+                    completed_count=Count(
+                        "id", filter=Q(issue__state__group="completed")
+                    ),
+                )
+                .order_by("created_at__date")
+            )
+
+            # Create a dictionary of existing stats with summed counts
+            stats_dict = {
+                stat["created_at__date"].strftime("%Y-%m-%d"): {
+                    "created_count": stat["created_count"],
+                    "completed_count": stat["completed_count"],
+                }
+                for stat in daily_stats
+            }
+
+            # Generate data for all days in the range
+            data = []
+            current_date = start_date
+            while current_date <= end_date:
+                date_str = current_date.strftime("%Y-%m-%d")
+                stats = stats_dict.get(
+                    date_str, {"created_count": 0, "completed_count": 0}
+                )
+                data.append(
+                    {
+                        "key": date_str,
+                        "name": date_str,
+                        "count": stats["created_count"] + stats["completed_count"],
+                        "completed_issues": stats["completed_count"],
+                        "created_issues": stats["created_count"],
+                    }
+                )
+                current_date += timedelta(days=1)
+        else:
+            # Apply date range filter if available
+            if self.filters["chart_period_range"]:
+                start_date, end_date = self.filters["chart_period_range"]
+                queryset = queryset.filter(
+                    created_at__date__gte=start_date, created_at__date__lte=end_date
+                )
path: apiserver/plane/app/views/analytic/project_analytics.py
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Missing project_id filter in monthly chart data**

When generating monthly chart data (the else branch of the chart endpoint), the code doesn't filter by project_id. This will cause the endpoint to return workspace-wide data instead of project-specific data.


```diff
            if self.filters["chart_period_range"]:
                start_date, end_date = self.filters["chart_period_range"]
                queryset = queryset.filter(
-                    created_at__date__gte=start_date, created_at__date__lte=end_date
+                    created_at__date__gte=start_date, created_at__date__lte=end_date, 
+                    project_id=project_id
                )
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
            # Apply date range filter if available
            if self.filters["chart_period_range"]:
                start_date, end_date = self.filters["chart_period_range"]
                queryset = queryset.filter(
                    created_at__date__gte=start_date, created_at__date__lte=end_date,
                    project_id=project_id
                )
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apiserver/plane/app/views/analytic/project_analytics.py around lines 317 to
322, the queryset filtering for monthly chart data is missing a filter for
project_id, causing it to return data for the entire workspace instead of the
specific project. Add a filter condition to the queryset to restrict results to
the current project_id, ensuring the data returned is project-specific.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T11:29:21Z
author_association: CONTRIBUTOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-05-22T09:09:59Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

This update introduces new project-scoped advanced analytics API endpoints and view classes on the backend, modifies analytics-related service methods to support a new "peek view" mode, and adjusts frontend components to pass the `isPeekView` flag as a separate parameter. Filtering by cycle, module, and peek view is removed from workspace-level analytics views, and URL construction is centralized in the analytics service.

## Changes

| File(s)                                                                                               | Change Summary |
|-----------------------------------------------------------------------------------------------------|----------------|
| `apiserver/plane/app/urls/analytic.py`                                                              | Added three new project-level advanced analytics URL patterns and imported corresponding view classes. |
| `apiserver/plane/app/views/__init__.py`                                                             | Imported new project analytics endpoint classes for exposure in the package. |
| `apiserver/plane/app/views/analytic/advance.py`                                                     | Removed all filtering by cycle, module, and peek view; simplified methods to only workspace-level stats and charts. Updated method signatures accordingly and removed unused imports. |
| `apiserver/plane/app/views/analytic/project_analytics.py`                                           | Introduced new module with project-scoped advanced analytics endpoints and supporting view classes for stats and charts, including permission checks and project/cycle/module filtering. |
| `web/core/services/analytics-v2.service.ts`                                                         | Refactored analytics service methods to accept `isPeekView` as a separate argument, centralized URL construction with new helper, and conditionally routed to project-level endpoints for peek view. Updated method signatures. |
| `web/core/components/analytics-v2/overview/project-insights.tsx`<br>`web/core/components/analytics-v2/total-insights.tsx`<br>`web/core/components/analytics-v2/work-items/created-vs-resolved.tsx`<br>`web/core/components/analytics-v2/work-items/priority-chart.tsx`<br>`web/core/components/analytics-v2/work-items/workitems-insight-table.tsx` | Updated all analytics data-fetching calls to pass `isPeekView` as a separate argument instead of embedding it in the options object. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant User
    participant Frontend
    participant AnalyticsV2Service
    participant BackendAPI

    User->>Frontend: Triggers analytics fetch (with isPeekView)
    Frontend->>AnalyticsV2Service: getAdvanceAnalytics* (params, isPeekView)
    AnalyticsV2Service->>AnalyticsV2Service: processUrl(..., isPeekView)
    AnalyticsV2Service->>BackendAPI: GET /workspaces/{slug}/projects/{projectId}/advance-analytics* (if isPeekView)
    BackendAPI-->>AnalyticsV2Service: analytics data
    AnalyticsV2Service-->>Frontend: analytics data
    Frontend-->>User: Display analytics
```

## Suggested labels

`🛠️refactor`, `🧹chore`, `ready_to_merge`

## Suggested reviewers

- dheeru0198

## Poem

> The data hops from view to view,  
> With peek mode shining bright and new.  
> No cycles, modules in the way—  
> Just project stats, the rabbit’s way!  
> URLs now smart and neat,  
> Analytics charts are quite a treat.  
> 🐇✨ Hooray for code that’s clean and fleet!

<!-- walkthrough_end -->

<!-- announcements_start -->

> [!NOTE]
> <details>
> <summary>⚡️ AI Code Reviews for VS Code, Cursor, Windsurf</summary>
> 
> CodeRabbit now has a plugin for VS Code, Cursor and Windsurf. This brings AI code reviews directly in the code editor. Each commit is reviewed immediately, finding bugs before the PR is raised. Seamless context handoff to your AI code agent ensures that you can easily incorporate review feedback.
> Learn more [here](http://coderabbit.ai/ide).
> 
> </details>

---

> [!NOTE]
> <details>
> <summary>⚡️ Faster reviews with caching</summary>
> 
> CodeRabbit now supports caching for code and dependencies, helping speed up reviews. This means quicker feedback, reduced wait times, and a smoother review experience overall. Cached data is encrypted and stored securely. This feature will be automatically enabled for all accounts on May 30th. To opt out, configure `Review - Disable Cache` at either the organization or repository level. If you prefer to disable all data retention across your organization, simply turn off the `Data Retention` setting under your Organization Settings.
> Enjoy the performance boost—your workflow just got faster.
> 
> </details>

<!-- announcements_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=makeplane/plane&utm_content=7105):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Explain this complex logic.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR
user: login: makeplane[bot]
created_at: 2025-05-22T09:10:06Z
body: Pull Request Linked with Plane Work Items

- [WEB-4133] [Analytics v2.0 commercial Version Fronend implementation](https://app.plane.so/plane/projects/02c3e1d5-d7e2-401d-a773-45ecba45d745/issues/2684e874-6cad-48b1-9b43-ef3e7a2d901b)


Comment Automatically Generated by [Plane](https://plane.so)

author_association: NONE