chore: revamped the analytics for cycle and module in peek view. (#7075)

* chore: added cycles and modules in analytics peek view

* chore: added cycles and modules analytics

* chore: added project filter for work items

* chore: added a peekview flag and based on that table columns

* chore: added peek view

* chore: added check for display name

* chore: cleaned up some code

* chore: fixed export csv data

* chore: added distinct work items

* chore: assignee in peek view

* updated csv fields

* chore: updated workitems peek with assignee

* fix: removed type assersions for workspaceslug

* chore: added day wise filter in cycles and modules

* chore: added extra validations

---------

Co-authored-by: JayashTripathy <jayashtripathy371@gmail.com>
This commit is contained in:
Bavisetti Narayan 2025-05-17 17:11:26 +05:30 committed by GitHub
parent ba158d5d6e
commit 5b776392bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 642 additions and 236 deletions

View file

@ -11,7 +11,6 @@ from plane.app.views import (
AdvanceAnalyticsChartEndpoint,
DefaultAnalyticsEndpoint,
ProjectStatsEndpoint,
AdvanceAnalyticsExportEndpoint,
)
@ -68,9 +67,4 @@ urlpatterns = [
AdvanceAnalyticsChartEndpoint.as_view(),
name="advance-analytics-chart",
),
path(
"workspaces/<str:slug>/advance-analytics-export/",
AdvanceAnalyticsExportEndpoint.as_view(),
name="advance-analytics-export",
),
]

View file

@ -203,7 +203,6 @@ from .analytic.advance import (
AdvanceAnalyticsEndpoint,
AdvanceAnalyticsStatsEndpoint,
AdvanceAnalyticsChartEndpoint,
AdvanceAnalyticsExportEndpoint,
)
from .notification.base import (

View file

@ -5,7 +5,7 @@ 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 (
@ -16,19 +16,18 @@ from plane.db.models import (
Module,
IssueView,
ProjectPage,
Workspace
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.bgtasks.analytic_plot_export import export_analytics_to_csv_email
from plane.utils.date_utils import (
get_analytics_filters,
)
from plane.utils.build_chart import build_analytics_chart
from plane.bgtasks.analytic_plot_export import export_analytics_to_csv_email
from plane.utils.date_utils import get_analytics_filters
class AdvanceAnalyticsBaseView(BaseAPIView):
def initialize_workspace(self, slug: str, type: str) -> None:
@ -73,7 +72,7 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
return {
"count": get_filtered_count(),
"filter_count": get_previous_count(),
# "filter_count": get_previous_count(),
}
def get_overview_data(self) -> Dict[str, Dict[str, int]]:
@ -120,9 +119,25 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
),
}
def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
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"])
return {
"total_work_items": self.get_filtered_counts(base_queryset),
@ -150,13 +165,14 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
self.get_overview_data(),
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(),
self.get_work_items_stats(cycle_id=cycle_id, module_id=module_id),
status=status.HTTP_200_OK,
)
return Response({"message": "Invalid tab"}, status=status.HTTP_400_BAD_REQUEST)
@ -184,14 +200,100 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
.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")
)
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], level="WORKSPACE")
def get(self, request: HttpRequest, slug: 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)
peek_view = request.GET.get("peek_view", False)
return Response(
self.get_project_issues_stats(),
self.get_work_items_stats(
cycle_id=cycle_id, module_id=module_id, peek_view=peek_view
),
status=status.HTTP_200_OK,
)
@ -251,7 +353,9 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
for key, value in data.items()
]
def work_item_completion_chart(self) -> Dict[str, Any]:
def work_item_completion_chart(
self, cycle_id=None, module_id=None, peek_view=False
) -> Dict[str, Any]:
# Get the base queryset
queryset = (
Issue.issue_objects.filter(**self.filters["base_filters"])
@ -261,61 +365,143 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
)
)
# 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(completed_at__isnull=False)),
)
.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 = []
workspace = Workspace.objects.get(slug=self._workspace_slug)
start_date = workspace.created_at.date().replace(day=1)
# 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"
], # <- Total created issues in that month
"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)
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:
current_month = current_month.replace(month=current_month.month + 1)
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
}
# 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",
@ -330,12 +516,13 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
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)
elif type == "custom-work-items":
# Get the base queryset
queryset = (
Issue.issue_objects.filter(**self.filters["base_filters"])
.select_related("workspace", "state", "parent")
@ -344,6 +531,19 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
)
)
# 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"]
@ -357,66 +557,15 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
)
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(),
self.work_item_completion_chart(
cycle_id=cycle_id, module_id=module_id, peek_view=peek_view
),
status=status.HTTP_200_OK,
)
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
class AdvanceAnalyticsExportEndpoint(AdvanceAnalyticsBaseView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request: HttpRequest, slug: str) -> Response:
self.initialize_workspace(slug, type="chart")
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"]
queryset = queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
queryset = (
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")
)
# Convert QuerySet to list of dictionaries for serialization
serialized_data = list(queryset)
headers = [
"Projects",
"Completed Issues",
"Backlog Issues",
"Unstarted Issues",
"Started Issues",
]
keys = [
"project__name",
"completed_work_items",
"backlog_work_items",
"un_started_work_items",
"started_work_items",
]
email = request.user.email
# Send serialized data to background task
export_analytics_to_csv_email.delay(serialized_data, headers, keys, email, slug)
return Response(
{
"message": f"Once the export is ready it will be emailed to you at {str(email)}"
},
status=status.HTTP_200_OK,
)

View file

@ -174,8 +174,8 @@ def get_analytics_filters(
"workspace__slug": slug,
"project_projectmember__member": user,
"project_projectmember__is_active": True,
"project__deleted_at__isnull": True,
"project__archived_at__isnull": True,
"deleted_at__isnull": True,
"archived_at__isnull": True,
}
# Add project IDs to filters if provided

View file

@ -1,52 +1,55 @@
import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/constants";
import { TChartData } from "./charts";
export type TAnalyticsTabsV2Base = "overview" | "work-items"
export type TAnalyticsGraphsV2Base = "projects" | "work-items" | "custom-work-items"
export type TAnalyticsTabsV2Base = "overview" | "work-items";
export type TAnalyticsGraphsV2Base = "projects" | "work-items" | "custom-work-items";
// service types
export interface IAnalyticsResponseV2 {
[key: string]: any;
[key: string]: any;
}
export interface IAnalyticsResponseFieldsV2 {
count: number;
filter_count: number;
count: number;
filter_count: number;
}
export interface IAnalyticsRadarEntityV2 {
key: string,
name: string,
count: number
key: string;
name: string;
count: number;
}
// chart types
export interface IChartResponseV2 {
schema: Record<string, string>;
data: TChartData<string, string>[];
schema: Record<string, string>;
data: TChartData<string, string>[];
}
// table types
export interface WorkItemInsightColumns {
project_id: string;
project__name: string;
cancelled_work_items: number;
completed_work_items: number;
backlog_work_items: number;
un_started_work_items: number;
started_work_items: number;
project_id?: string;
project__name?: string;
cancelled_work_items: number;
completed_work_items: number;
backlog_work_items: number;
un_started_work_items: number;
started_work_items: number;
// because of the peek view, we will display the name of the project instead of project__name
display_name?: string;
avatar_url?: string;
assignee_id?: string;
}
export type AnalyticsTableDataMap = {
"work-items": WorkItemInsightColumns,
}
"work-items": WorkItemInsightColumns;
};
export interface IAnalyticsV2Params {
x_axis: ChartXAxisProperty;
y_axis: ChartYAxisMetric;
group_by?: ChartXAxisProperty;
}
x_axis: ChartXAxisProperty;
y_axis: ChartYAxisMetric;
group_by?: ChartXAxisProperty;
}

View file

@ -27,7 +27,7 @@ import {
// ui
import { Breadcrumbs, Button, ContrastIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
import { CycleQuickActions } from "@/components/cycles";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
@ -161,7 +161,8 @@ export const CycleIssuesHeader: React.FC = observer(() => {
return (
<>
<ProjectAnalyticsModal
<WorkItemsModal
projectDetails={currentProjectDetails}
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
cycleDetails={cycleDetails ?? undefined}

View file

@ -19,7 +19,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
// ui
import { CustomMenu } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown, IssueLayoutIcon } from "@/components/issues";
// helpers
import { isIssueFilterActive } from "@/helpers/filter.helper";
@ -123,7 +123,8 @@ export const CycleIssuesMobileHeader = () => {
return (
<>
<ProjectAnalyticsModal
<WorkItemsModal
projectDetails={currentProjectDetails}
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
cycleDetails={cycleDetails ?? undefined}

View file

@ -25,7 +25,7 @@ import {
// ui
import { Breadcrumbs, Button, DiceIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// helpers
@ -155,10 +155,11 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
return (
<>
<ProjectAnalyticsModal
<WorkItemsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
moduleDetails={moduleDetails ?? undefined}
projectDetails={currentProjectDetails}
/>
<Header>
<Header.LeftItem>

View file

@ -21,6 +21,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
import { CustomMenu } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
import {
DisplayFiltersSelection,
FilterSelection,
@ -106,10 +107,11 @@ export const ModuleIssuesMobileHeader = observer(() => {
return (
<div className="block md:hidden">
<ProjectAnalyticsModal
<WorkItemsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
moduleDetails={moduleDetails ?? undefined}
projectDetails={currentProjectDetails}
/>
<div className="flex justify-evenly border-b border-custom-border-200 bg-custom-background-100 py-2">
<CustomMenu

View file

@ -21,7 +21,7 @@ export const InsightTable = <T extends Exclude<TAnalyticsTabsV2Base, "overview">
const { data, isLoading, columns, columnsLabels } = props;
const params = useParams();
const { t } = useTranslation();
const workspaceSlug = params.workspaceSlug as string;
const workspaceSlug = params.workspaceSlug.toString();
if (isLoading) {
return <TableLoader columns={columns} rows={5} />;
}
@ -35,7 +35,7 @@ export const InsightTable = <T extends Exclude<TAnalyticsTabsV2Base, "overview">
const exportCSV = (rows: Row<AnalyticsTableDataMap[T]>[]) => {
const rowData: any = rows.map((row) => {
const { project_id, ...exportableData } = row.original;
const { project_id, avatar_url, assignee_id, ...exportableData } = row.original;
return Object.fromEntries(
Object.entries(exportableData).map(([key, value]) => {
if (columnsLabels?.[key]) {

View file

@ -26,16 +26,20 @@ const analyticsV2Service = new AnalyticsV2Service();
const ProjectInsights = observer(() => {
const params = useParams();
const { t } = useTranslation();
const workspaceSlug = params.workspaceSlug as string;
const { selectedDuration, selectedDurationLabel, selectedProjects } = useAnalyticsV2();
const workspaceSlug = params.workspaceSlug.toString();
const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } =
useAnalyticsV2();
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-radar" });
const { data: projectInsightsData, isLoading: isLoadingProjectInsight } = useSWR(
`radar-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
`radar-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
() =>
analyticsV2Service.getAdvanceAnalyticsCharts<TChartData<string, string>[]>(workspaceSlug, "projects", {
// date_filter: selectedDuration,
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
...(selectedModule ? { module_id: selectedModule } : {}),
...(isPeekView ? { peek_view: true } : {}),
})
);

View file

@ -18,16 +18,20 @@ const analyticsV2Service = new AnalyticsV2Service();
const TotalInsights: React.FC<{ analyticsType: TAnalyticsTabsV2Base; peekView?: boolean }> = observer(
({ analyticsType, peekView }) => {
const params = useParams();
const workspaceSlug = params.workspaceSlug as string;
const workspaceSlug = params.workspaceSlug.toString();
const { t } = useTranslation();
const { selectedDuration, selectedProjects, selectedDurationLabel } = useAnalyticsV2();
const { selectedDuration, selectedProjects, selectedDurationLabel, selectedCycle, selectedModule, isPeekView } =
useAnalyticsV2();
const { data: totalInsightsData, isLoading } = useSWR(
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}`,
`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 } : {}),
})
);
return (

View file

@ -19,17 +19,21 @@ import { ChartLoader } from "../loaders";
const analyticsV2Service = new AnalyticsV2Service();
const CreatedVsResolved = observer(() => {
const { selectedDuration, selectedDurationLabel, selectedProjects } = useAnalyticsV2();
const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } =
useAnalyticsV2();
const params = useParams();
const { t } = useTranslation();
const workspaceSlug = params.workspaceSlug as string;
const workspaceSlug = params.workspaceSlug.toString();
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-area" });
const { data: createdVsResolvedData, isLoading: isCreatedVsResolvedLoading } = useSWR(
`created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
`created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
() =>
analyticsV2Service.getAdvanceAnalyticsCharts<IChartResponseV2>(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 } : {}),
})
);
const parsedData: TChartData<string, string>[] = useMemo(() => {

View file

@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { Tab } from "@headlessui/react";
// plane package imports
import { IProject } from "@plane/types";
import { ICycle, IModule, IProject } from "@plane/types";
import { Spinner } from "@plane/ui";
// hooks
import { useAnalyticsV2 } from "@/hooks/store";
@ -15,20 +15,52 @@ import WorkItemsInsightTable from "../workitems-insight-table";
type Props = {
fullScreen: boolean;
projectDetails: IProject | undefined;
cycleDetails: ICycle | undefined;
moduleDetails: IModule | undefined;
};
export const WorkItemsModalMainContent: React.FC<Props> = observer((props) => {
const { projectDetails, fullScreen } = props;
const { updateSelectedProjects } = useAnalyticsV2();
const [isProjectConfigured, setIsProjectConfigured] = useState(false);
const { projectDetails, cycleDetails, moduleDetails, fullScreen } = props;
const { updateSelectedProjects, updateSelectedCycle, updateSelectedModule, updateIsPeekView } = useAnalyticsV2();
const [isModalConfigured, setIsModalConfigured] = useState(false);
useEffect(() => {
if (!projectDetails?.id) return;
updateSelectedProjects([projectDetails?.id ?? ""]);
setIsProjectConfigured(true);
}, [projectDetails?.id, updateSelectedProjects]);
updateIsPeekView(true);
if (!isProjectConfigured)
// Handle project selection
if (projectDetails?.id) {
updateSelectedProjects([projectDetails.id]);
}
// Handle cycle selection
if (cycleDetails?.id) {
updateSelectedCycle(cycleDetails.id);
}
// Handle module selection
if (moduleDetails?.id) {
updateSelectedModule(moduleDetails.id);
}
setIsModalConfigured(true);
// Cleanup fields
return () => {
updateSelectedProjects([]);
updateSelectedCycle("");
updateSelectedModule("");
updateIsPeekView(false);
};
}, [
projectDetails?.id,
cycleDetails?.id,
moduleDetails?.id,
updateSelectedProjects,
updateSelectedCycle,
updateSelectedModule,
updateIsPeekView,
]);
if (!isModalConfigured)
return (
<div className="flex h-full items-center justify-center">
<Spinner />

View file

@ -1,21 +1,26 @@
import { observer } from "mobx-react";
// icons
// plane package imports
import { Expand, Shrink, X } from "lucide-react";
import { ICycle, IModule } from "@plane/types";
// icons
type Props = {
fullScreen: boolean;
handleClose: () => void;
setFullScreen: React.Dispatch<React.SetStateAction<boolean>>;
title: string;
cycle?: ICycle;
module?: IModule;
};
export const WorkItemsModalHeader: React.FC<Props> = observer((props) => {
const { fullScreen, handleClose, setFullScreen, title } = props;
const { fullScreen, handleClose, setFullScreen, title, cycle, module } = props;
return (
<div className="flex items-center justify-between gap-4 bg-custom-background-100 px-5 py-4 text-sm">
<h3 className="break-words">Analytics for {title}</h3>
<h3 className="break-words">
Analytics for {title} {cycle && `in ${cycle.name}`} {module && `in ${module.name}`}
</h3>
<div className="flex items-center gap-2">
<button
type="button"

View file

@ -2,7 +2,7 @@ import React, { useState } from "react";
import { observer } from "mobx-react";
import { Dialog, Transition } from "@headlessui/react";
// plane package imports
import { IProject } from "@plane/types";
import { ICycle, IModule, IProject } from "@plane/types";
// plane web components
import { WorkItemsModalMainContent } from "./content";
import { WorkItemsModalHeader } from "./header";
@ -11,10 +11,12 @@ type Props = {
isOpen: boolean;
onClose: () => void;
projectDetails?: IProject | undefined;
cycleDetails?: ICycle | undefined;
moduleDetails?: IModule | undefined;
};
export const WorkItemsModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose, projectDetails } = props;
const { isOpen, onClose, projectDetails, moduleDetails, cycleDetails } = props;
const [fullScreen, setFullScreen] = useState(false);
@ -51,8 +53,15 @@ export const WorkItemsModal: React.FC<Props> = observer((props) => {
handleClose={handleClose}
setFullScreen={setFullScreen}
title={projectDetails?.name ?? ""}
cycle={cycleDetails}
module={moduleDetails}
/>
<WorkItemsModalMainContent
fullScreen={fullScreen}
projectDetails={projectDetails}
cycleDetails={cycleDetails}
moduleDetails={moduleDetails}
/>
<WorkItemsModalMainContent fullScreen={fullScreen} projectDetails={projectDetails} />
</div>
</div>
</Dialog.Panel>

View file

@ -46,19 +46,23 @@ const PriorityChart = observer((props: Props) => {
const { t } = useTranslation();
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-bar" });
// store hooks
const { selectedDuration, selectedProjects } = useAnalyticsV2();
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView } = useAnalyticsV2();
const { workspaceStates } = useProjectState();
const { resolvedTheme } = useTheme();
// router
const params = useParams();
const workspaceSlug = params.workspaceSlug as string;
const workspaceSlug = params.workspaceSlug.toString();
const { data: priorityChartData, isLoading: priorityChartLoading } = useSWR(
`customized-insights-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${props.x_axis}-${props.y_axis}-${props.group_by}`,
`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,
})
);
@ -158,10 +162,23 @@ const PriorityChart = observer((props: Props) => {
});
const exportCSV = (rows: Row<TChartDatum>[]) => {
const rowData = rows.map((row) => ({
name: row.original.name,
count: row.original.count,
}));
const rowData = rows.map((row) => {
const hiddenFields = ["key", "avatar_url", "assignee_id", "project_id"];
const otherFields = Object.keys(row.original).filter(
(key) => key !== "name" && key !== "count" && !hiddenFields.includes(key) && !key.includes("id")
);
return {
name: row.original.name,
count: row.original.count,
...otherFields.reduce(
(acc, key) => {
acc[parsedData?.schema[key] ?? key] = row.original[key];
return acc;
},
{} as Record<string, string | number>
),
};
});
const csv = generateCsv(csvConfig)(rowData);
download(csvConfig)(csv);
};

View file

@ -1,13 +1,15 @@
import { useMemo } from "react";
import { ColumnDef } from "@tanstack/react-table";
import { ColumnDef, Row } from "@tanstack/react-table";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { Briefcase } from "lucide-react";
import { Briefcase, UserRound } from "lucide-react";
// plane package imports
import { useTranslation } from "@plane/i18n";
import { WorkItemInsightColumns, AnalyticsTableDataMap } from "@plane/types";
// plane web components
import { Avatar } from "@plane/ui";
import { getFileURL } from "@plane/utils";
import { Logo } from "@/components/common/logo";
// hooks
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
@ -21,44 +23,85 @@ const analyticsV2Service = new AnalyticsV2Service();
const WorkItemsInsightTable = observer(() => {
// router
const params = useParams();
const workspaceSlug = params.workspaceSlug as string;
const workspaceSlug = params.workspaceSlug.toString();
const { t } = useTranslation();
// store hooks
const { getProjectById } = useProject();
const { selectedDuration, selectedProjects } = useAnalyticsV2();
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView } = useAnalyticsV2();
const { data: workItemsData, isLoading } = useSWR(
`insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
`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 } : {}),
})
);
// derived values
const columnsLabels: Record<string, string> = {
backlog_work_items: t("workspace_projects.state.backlog"),
started_work_items: t("workspace_projects.state.started"),
un_started_work_items: t("workspace_projects.state.unstarted"),
completed_work_items: t("workspace_projects.state.completed"),
cancelled_work_items: t("workspace_projects.state.cancelled"),
project__name: t("common.project"),
};
const columnsLabels = useMemo(
() => ({
backlog_work_items: t("workspace_projects.state.backlog"),
started_work_items: t("workspace_projects.state.started"),
un_started_work_items: t("workspace_projects.state.unstarted"),
completed_work_items: t("workspace_projects.state.completed"),
cancelled_work_items: t("workspace_projects.state.cancelled"),
project__name: t("common.project"),
display_name: t("common.assignee"),
}),
[t]
);
const columns = useMemo(
() =>
[
{
accessorKey: "project__name",
header: () => <div className="text-left">{columnsLabels["project__name"]}</div>,
cell: ({ row }) => {
const project = getProjectById(row.original.project_id);
return (
<div className="flex items-center gap-2">
{project?.logo_props ? <Logo logo={project.logo_props} size={18} /> : <Briefcase className="h-4 w-4" />}
{project?.name}
</div>
);
},
},
!isPeekView
? {
accessorKey: "project__name",
header: () => <div className="text-left">{columnsLabels["project__name"]}</div>,
cell: ({ row }) => {
const project = getProjectById(row.original.project_id);
return (
<div className="flex items-center gap-2">
{project?.logo_props ? (
<Logo logo={project.logo_props} size={18} />
) : (
<Briefcase className="h-4 w-4" />
)}
{project?.name}
</div>
);
},
}
: {
accessorKey: "display_name",
header: () => <div className="text-left">{columnsLabels["display_name"]}</div>,
cell: ({ row }: { row: Row<WorkItemInsightColumns> }) => (
<div className="text-left">
<div className="flex items-center gap-2">
{row.original.avatar_url && row.original.avatar_url !== "" ? (
<Avatar
name={row.original.display_name}
src={getFileURL(row.original.avatar_url)}
size={24}
shape="circle"
/>
) : (
<div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-custom-background-80 capitalize overflow-hidden">
{row.original.display_name ? (
row.original.display_name?.[0]
) : (
<UserRound className="text-custom-text-200 " size={12} />
)}
</div>
)}
<span className="break-words text-custom-text-200">
{row.original.display_name ?? t(`Unassigned`)}
</span>
</div>
</div>
),
},
{
accessorKey: "backlog_work_items",
header: () => <div className="text-right">{columnsLabels["backlog_work_items"]}</div>,
@ -85,7 +128,7 @@ const WorkItemsInsightTable = observer(() => {
cell: ({ row }) => <div className="text-right">{row.original.cancelled_work_items}</div>,
},
] as ColumnDef<AnalyticsTableDataMap["work-items"]>[],
[getProjectById]
[columnsLabels, getProjectById, isPeekView, t]
);
return (

View file

@ -0,0 +1,107 @@
"use client";
import React, { Fragment } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { Tab } from "@headlessui/react";
// plane package imports
import { ANALYTICS_TABS, EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Header, EHeaderVariant } from "@plane/ui";
// components
import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics";
import { PageHead } from "@/components/core";
import { ComicBoxButton, DetailedEmptyState } from "@/components/empty-state";
// hooks
import { useCommandPalette, useEventTracker, useProject, useUserPermissions, useWorkspace } from "@/hooks/store";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
const OldAnalyticsPage = observer(() => {
const searchParams = useSearchParams();
const analytics_tab = searchParams.get("analytics_tab");
// plane imports
const { t } = useTranslation();
// store hooks
const { toggleCreateProjectModal } = useCommandPalette();
const { setTrackElement } = useEventTracker();
const { workspaceProjectIds, loader } = useProject();
const { currentWorkspace } = useWorkspace();
const { allowPermissions } = useUserPermissions();
// helper hooks
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/analytics" });
// derived values
const pageTitle = currentWorkspace?.name
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
: undefined;
// permissions
const canPerformEmptyStateActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
// TODO: refactor loader implementation
return (
<>
<PageHead title={pageTitle} />
{workspaceProjectIds && (
<>
{workspaceProjectIds.length > 0 || loader === "init-loader" ? (
<div className="flex h-full flex-col overflow-hidden bg-custom-background-100">
<Tab.Group as={Fragment} defaultIndex={analytics_tab === "custom" ? 1 : 0}>
<Header variant={EHeaderVariant.SECONDARY}>
<Tab.List as="div" className="flex space-x-2 h-full">
{ANALYTICS_TABS.map((tab) => (
<Tab key={tab.key} as={Fragment}>
{({ selected }) => (
<button
className={`text-sm group relative flex items-center gap-1 h-full px-3 cursor-pointer transition-all font-medium outline-none ${
selected ? "text-custom-primary-100 " : "hover:text-custom-text-200"
}`}
>
{t(tab.i18n_title)}
<div
className={`border absolute bottom-0 right-0 left-0 rounded-t-md ${selected ? "border-custom-primary-100" : "border-transparent group-hover:border-custom-border-200"}`}
/>
</button>
)}
</Tab>
))}
</Tab.List>
</Header>
<Tab.Panels as={Fragment}>
<Tab.Panel as={Fragment}>
<ScopeAndDemand fullScreen />
</Tab.Panel>
<Tab.Panel as={Fragment}>
<CustomAnalytics fullScreen />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
) : (
<DetailedEmptyState
title={t("workspace_analytics.empty_state.general.title")}
description={t("workspace_analytics.empty_state.general.description")}
assetPath={resolvedPath}
customPrimaryButton={
<ComicBoxButton
label={t("workspace_analytics.empty_state.general.primary_button.text")}
title={t("workspace_analytics.empty_state.general.primary_button.comic.title")}
description={t("workspace_analytics.empty_state.general.primary_button.comic.description")}
onClick={() => {
setTrackElement("Analytics empty state");
toggleCreateProjectModal(true);
}}
disabled={!canPerformEmptyStateActions}
/>
}
/>
)}
</>
)}
</>
);
});
export default OldAnalyticsPage;

View file

@ -10,6 +10,9 @@ export interface IAnalyticsStoreV2 {
currentTab: TAnalyticsTabsV2Base;
selectedProjects: string[];
selectedDuration: DurationType;
selectedCycle: string;
selectedModule: string;
isPeekView?: boolean;
//computed
selectedDurationLabel: DurationType | null;
@ -17,25 +20,36 @@ export interface IAnalyticsStoreV2 {
//actions
updateSelectedProjects: (projects: string[]) => void;
updateSelectedDuration: (duration: DurationType) => void;
updateSelectedCycle: (cycle: string) => void;
updateSelectedModule: (module: string) => void;
updateIsPeekView: (isPeekView: boolean) => void;
}
export class AnalyticsStoreV2 implements IAnalyticsStoreV2 {
//observables
currentTab: TAnalyticsTabsV2Base = "overview";
selectedProjects: DurationType[] = [];
selectedProjects: string[] = [];
selectedDuration: DurationType = "last_30_days";
constructor(_rootStore: CoreRootStore) {
selectedCycle: string = "";
selectedModule: string = "";
isPeekView: boolean = false;
constructor() {
makeObservable(this, {
// observables
currentTab: observable.ref,
selectedDuration: observable.ref,
selectedProjects: observable.ref,
selectedCycle: observable.ref,
selectedModule: observable.ref,
isPeekView: observable.ref,
// computed
selectedDurationLabel: computed,
// actions
updateSelectedProjects: action,
updateSelectedDuration: action,
updateSelectedCycle: action,
updateSelectedModule: action,
updateIsPeekView: action,
});
}
@ -44,7 +58,6 @@ export class AnalyticsStoreV2 implements IAnalyticsStoreV2 {
}
updateSelectedProjects = (projects: string[]) => {
const initialState = this.selectedProjects;
try {
runInAction(() => {
this.selectedProjects = projects;
@ -65,4 +78,22 @@ export class AnalyticsStoreV2 implements IAnalyticsStoreV2 {
throw error;
}
};
updateSelectedCycle = (cycle: string) => {
runInAction(() => {
this.selectedCycle = cycle;
});
};
updateSelectedModule = (module: string) => {
runInAction(() => {
this.selectedModule = module;
});
};
updateIsPeekView = (isPeekView: boolean) => {
runInAction(() => {
this.isPeekView = isPeekView;
});
};
}

View file

@ -96,7 +96,7 @@ export class CoreRootStore {
this.transient = new TransientStore();
this.stickyStore = new StickyStore();
this.editorAssetStore = new EditorAssetStore();
this.analyticsV2 = new AnalyticsStoreV2(this);
this.analyticsV2 = new AnalyticsStoreV2();
}
resetOnSignOut() {