[WEB-412] chore: estimates analytics (#4730)

* chore: estimate points in modules and cycle

* chore: burn down chart analytics

* chore: module serializer change

* dev: handled y-axis estimates in analytics, implemented estimate points on modules

* chore: burn down analytics

* chore: state estimate point analytics

* chore: updated the burn down values

* Remove check mark from estimate point edit field in
create estimate flow

---------

Co-authored-by: guru_sainath <gurusainath007@gmail.com>
Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
This commit is contained in:
Bavisetti Narayan 2024-06-07 18:19:45 +05:30 committed by GitHub
parent 23f4d5418f
commit dae291fe3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 458 additions and 111 deletions

View File

@ -784,6 +784,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
def post(self, request, slug, project_id, cycle_id):
new_cycle_id = request.data.get("new_cycle_id", False)
plot_type = request.GET.get("plot_type", "issues")
if not new_cycle_id:
return Response(
@ -865,6 +866,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
plot_type=plot_type,
cycle_id=cycle_id,
)

View File

@ -177,6 +177,8 @@ class ModuleSerializer(DynamicBaseSerializer):
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
total_estimate_points = serializers.IntegerField(read_only=True)
completed_estimate_points = serializers.IntegerField(read_only=True)
class Meta:
model = Module
@ -201,6 +203,8 @@ class ModuleSerializer(DynamicBaseSerializer):
"external_id",
"logo_props",
# computed fields
"total_estimate_points",
"completed_estimate_points",
"is_favorite",
"total_issues",
"cancelled_issues",

View File

@ -177,6 +177,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id, pk=None):
plot_type = request.GET.get("plot_type", "issues")
if pk is None:
queryset = (
self.get_queryset()
@ -375,6 +376,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
queryset=queryset,
slug=slug,
project_id=project_id,
plot_type=plot_type,
cycle_id=pk,
)

View File

@ -17,8 +17,11 @@ from django.db.models import (
UUIDField,
Value,
When,
Subquery,
Sum,
IntegerField,
)
from django.db.models.functions import Coalesce
from django.db.models.functions import Coalesce, Cast
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
@ -73,6 +76,89 @@ class CycleViewSet(BaseViewSet):
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
backlog_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="backlog",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
backlog_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("backlog_estimate_point")[:1]
)
unstarted_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="unstarted",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
unstarted_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("unstarted_estimate_point")[:1]
)
started_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="started",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
started_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("started_estimate_point")[:1]
)
cancelled_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="cancelled",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
cancelled_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("cancelled_estimate_point")[:1]
)
completed_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="completed",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
completed_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("completed_estimate_points")[:1]
)
total_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
total_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("total_estimate_points")[:1]
)
return self.filter_queryset(
super()
.get_queryset()
@ -197,12 +283,49 @@ class CycleViewSet(BaseViewSet):
Value([], output_field=ArrayField(UUIDField())),
)
)
.annotate(
backlog_estimate_points=Coalesce(
Subquery(backlog_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
unstarted_estimate_points=Coalesce(
Subquery(unstarted_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
started_estimate_points=Coalesce(
Subquery(started_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
cancelled_estimate_points=Coalesce(
Subquery(cancelled_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
completed_estimate_points=Coalesce(
Subquery(completed_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
total_estimate_points=Coalesce(
Subquery(total_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.order_by("-is_favorite", "name")
.distinct()
)
def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True)
plot_type = request.GET.get("plot_type", "issues")
cycle_view = request.GET.get("cycle_view", "all")
# Update the order by
@ -233,6 +356,12 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot",
"logo_props",
# meta fields
"backlog_estimate_points",
"unstarted_estimate_points",
"started_estimate_points",
"cancelled_estimate_points",
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"total_issues",
"cancelled_issues",
@ -335,6 +464,7 @@ class CycleViewSet(BaseViewSet):
queryset=queryset.first(),
slug=slug,
project_id=project_id,
plot_type=plot_type,
cycle_id=data[0]["id"],
)
)
@ -359,6 +489,8 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot",
"logo_props",
# meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"total_issues",
"cancelled_issues",
@ -527,6 +659,7 @@ class CycleViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk):
plot_type = request.GET.get("plot_type", "issues")
queryset = (
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
)
@ -682,6 +815,7 @@ class CycleViewSet(BaseViewSet):
queryset=queryset,
slug=slug,
project_id=project_id,
plot_type=plot_type,
cycle_id=pk,
)
@ -798,6 +932,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
def post(self, request, slug, project_id, cycle_id):
new_cycle_id = request.data.get("new_cycle_id", False)
plot_type = request.GET.get("plot_type", "issues")
if not new_cycle_id:
return Response(
@ -879,6 +1014,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
plot_type=plot_type,
cycle_id=cycle_id,
)

View File

@ -165,6 +165,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id, pk=None):
plot_type = request.GET.get("plot_type", "issues")
if pk is None:
queryset = self.get_queryset()
modules = queryset.values( # Required fields
@ -323,6 +324,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
queryset=modules,
slug=slug,
project_id=project_id,
plot_type=plot_type,
module_id=pk,
)

View File

@ -16,8 +16,9 @@ from django.db.models import (
Subquery,
UUIDField,
Value,
Sum,
)
from django.db.models.functions import Coalesce
from django.db.models.functions import Coalesce, Cast
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
@ -128,6 +129,34 @@ class ModuleViewSet(BaseViewSet):
.annotate(cnt=Count("pk"))
.values("cnt")
)
completed_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="completed",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
completed_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("completed_estimate_points")[:1]
)
total_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
total_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("total_estimate_points")[:1]
)
return (
super()
.get_queryset()
@ -182,6 +211,18 @@ class ModuleViewSet(BaseViewSet):
Value(0, output_field=IntegerField()),
)
)
.annotate(
completed_estimate_points=Coalesce(
Subquery(completed_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
total_estimate_points=Coalesce(
Subquery(total_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
member_ids=Coalesce(
ArrayAgg(
@ -233,6 +274,8 @@ class ModuleViewSet(BaseViewSet):
"total_issues",
"started_issues",
"unstarted_issues",
"completed_estimate_points",
"total_estimate_points",
"backlog_issues",
"created_at",
"updated_at",
@ -284,6 +327,8 @@ class ModuleViewSet(BaseViewSet):
"external_id",
"logo_props",
# computed fields
"completed_estimate_points",
"total_estimate_points",
"total_issues",
"is_favorite",
"cancelled_issues",
@ -301,6 +346,7 @@ class ModuleViewSet(BaseViewSet):
return Response(modules, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk):
plot_type = request.GET.get("plot_type", "burndown")
queryset = (
self.get_queryset()
.filter(archived_at__isnull=True)
@ -423,6 +469,7 @@ class ModuleViewSet(BaseViewSet):
queryset=modules,
slug=slug,
project_id=project_id,
plot_type=plot_type,
module_id=pk,
)
@ -469,6 +516,8 @@ class ModuleViewSet(BaseViewSet):
"external_id",
"logo_props",
# computed fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"cancelled_issues",
"completed_issues",

View File

@ -4,18 +4,28 @@ from itertools import groupby
# Django import
from django.db import models
from django.db.models import Case, CharField, Count, F, Sum, Value, When
from django.db.models import (
Case,
CharField,
Count,
F,
Sum,
Value,
When,
IntegerField,
)
from django.db.models.functions import (
Coalesce,
Concat,
ExtractMonth,
ExtractYear,
TruncDate,
Cast,
)
from django.utils import timezone
# Module imports
from plane.db.models import Issue
from plane.db.models import Issue, Project
def annotate_with_monthly_dimension(queryset, field_name, attribute):
@ -87,9 +97,9 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
# Estimate
else:
queryset = queryset.annotate(estimate=Sum("estimate_point")).order_by(
x_axis
)
queryset = queryset.annotate(
estimate=Sum(Cast("estimate_point__value", IntegerField()))
).order_by(x_axis)
queryset = (
queryset.annotate(segment=F(segment)) if segment else queryset
)
@ -110,9 +120,33 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
return sort_data(grouped_data, temp_axis)
def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
def burndown_plot(
queryset,
slug,
project_id,
plot_type,
cycle_id=None,
module_id=None,
):
# Total Issues in Cycle or Module
total_issues = queryset.total_issues
# check whether the estimate is a point or not
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if estimate_type and plot_type == "points":
issue_estimates = Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
estimate_point__isnull=False,
).values_list("estimate_point__value", flat=True)
issue_estimates = [int(value) for value in issue_estimates]
total_estimate_points = sum(issue_estimates)
if cycle_id:
if queryset.end_date and queryset.start_date:
@ -128,18 +162,32 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
chart_data = {str(date): 0 for date in date_range}
completed_issues_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
if plot_type == "points":
completed_issues_estimate_point_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
estimate_point__isnull=False,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.values("date", "estimate_point__value")
.order_by("date")
)
else:
completed_issues_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.annotate(total_completed=Count("id"))
.values("date", "total_completed")
.order_by("date")
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.annotate(total_completed=Count("id"))
.values("date", "total_completed")
.order_by("date")
)
if module_id:
# Get all dates between the two dates
@ -152,31 +200,59 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
chart_data = {str(date): 0 for date in date_range}
completed_issues_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_module__module_id=module_id,
if plot_type == "points":
completed_issues_estimate_point_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_module__module_id=module_id,
estimate_point__isnull=False,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.values("date", "estimate_point__value")
.order_by("date")
)
else:
completed_issues_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_module__module_id=module_id,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.annotate(total_completed=Count("id"))
.values("date", "total_completed")
.order_by("date")
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.annotate(total_completed=Count("id"))
.values("date", "total_completed")
.order_by("date")
)
for date in date_range:
cumulative_pending_issues = total_issues
total_completed = 0
total_completed = sum(
item["total_completed"]
for item in completed_issues_distribution
if item["date"] is not None and item["date"] <= date
)
cumulative_pending_issues -= total_completed
if date > timezone.now().date():
chart_data[str(date)] = None
if plot_type == "points":
cumulative_pending_issues = total_estimate_points
total_completed = 0
total_completed = sum(
int(item["estimate_point__value"])
for item in completed_issues_estimate_point_distribution
if item["date"] is not None and item["date"] <= date
)
cumulative_pending_issues -= total_completed
if date > timezone.now().date():
chart_data[str(date)] = None
else:
chart_data[str(date)] = cumulative_pending_issues
else:
chart_data[str(date)] = cumulative_pending_issues
cumulative_pending_issues = total_issues
total_completed = 0
total_completed = sum(
item["total_completed"]
for item in completed_issues_distribution
if item["date"] is not None and item["date"] <= date
)
cumulative_pending_issues -= total_completed
if date > timezone.now().date():
chart_data[str(date)] = None
else:
chart_data[str(date)] = cumulative_pending_issues
return chart_data

View File

@ -44,6 +44,8 @@ export interface IModule {
target_date: string | null;
total_issues: number;
unstarted_issues: number;
total_estimate_points?: number;
completed_estimate_points?: number;
updated_at: string;
updated_by?: string;
archived_at: string | null;

View File

@ -1,26 +1,54 @@
// ui
import { observer } from "mobx-react";
import { TYAxisValues } from "@plane/types";
import { CustomSelect } from "@plane/ui";
// types
import { ANALYTICS_Y_AXIS_VALUES } from "@/constants/analytics";
// constants
import { ANALYTICS_Y_AXIS_VALUES } from "@/constants/analytics";
import { EEstimateSystem } from "@/constants/estimates";
// hooks
import { useAppRouter, useProjectEstimates } from "@/hooks/store";
type Props = {
value: TYAxisValues;
onChange: () => void;
};
export const SelectYAxis: React.FC<Props> = ({ value, onChange }) => (
<CustomSelect
value={value}
label={<span>{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}</span>}
onChange={onChange}
maxHeight="lg"
>
{ANALYTICS_Y_AXIS_VALUES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
);
export const SelectYAxis: React.FC<Props> = observer(({ value, onChange }) => {
// hooks
const { projectId } = useAppRouter();
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
const isEstimateEnabled = (analyticsOption: string) => {
if (analyticsOption === "estimate") {
if (
projectId &&
currentActiveEstimateId &&
areEstimateEnabledByProjectId(projectId) &&
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS
) {
return true;
} else {
return false;
}
}
return true;
};
return (
<CustomSelect
value={value}
label={<span>{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}</span>}
onChange={onChange}
maxHeight="lg"
>
{ANALYTICS_Y_AXIS_VALUES.map(
(item) =>
isEstimateEnabled(item.value) && (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
)
)}
</CustomSelect>
);
});

View File

@ -1,4 +1,4 @@
import { FC, FormEvent, useState } from "react";
import { FC, MouseEvent, FocusEvent, useState } from "react";
import { observer } from "mobx-react";
import { Check, Info, X } from "lucide-react";
import { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types";
@ -49,7 +49,7 @@ export const EstimatePointCreate: FC<TEstimatePointCreate> = observer((props) =>
closeCallBack();
};
const handleCreate = async (event: FormEvent<HTMLFormElement>) => {
const handleCreate = async (event: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement, Element>) => {
event.preventDefault();
if (!workspaceSlug || !projectId) return;
@ -122,10 +122,10 @@ export const EstimatePointCreate: FC<TEstimatePointCreate> = observer((props) =>
};
return (
<form onSubmit={handleCreate} className="relative flex items-center gap-2 text-base">
<form className="relative flex items-center gap-2 text-base">
<div
className={cn(
"relative w-full border rounded flex items-center",
"relative w-full border rounded flex items-center my-1",
error ? `border-red-500` : `border-custom-border-200`
)}
>
@ -136,6 +136,7 @@ export const EstimatePointCreate: FC<TEstimatePointCreate> = observer((props) =>
className="border-none focus:ring-0 focus:border-0 focus:outline-none p-2.5 w-full bg-transparent"
placeholder="Enter estimate point"
autoFocus
onBlur={(e) => !estimateId && handleCreate(e)}
/>
{error && (
<>
@ -148,20 +149,25 @@ export const EstimatePointCreate: FC<TEstimatePointCreate> = observer((props) =>
)}
</div>
<button
type="submit"
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer text-green-500"
disabled={loader}
>
{loader ? <Spinner className="w-4 h-4" /> : <Check size={14} />}
</button>
<button
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer"
onClick={handleClose}
disabled={loader}
>
<X size={14} className="text-custom-text-200" />
</button>
{estimateId && (
<>
<button
type="submit"
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer text-green-500"
disabled={loader}
onClick={handleCreate}
>
{loader ? <Spinner className="w-4 h-4" /> : <Check size={14} />}
</button>
<button
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer"
onClick={handleClose}
disabled={loader}
>
<X size={14} className="text-custom-text-200" />
</button>
</>
)}
</form>
);
});

View File

@ -1,4 +1,4 @@
import { FC, FormEvent, useEffect, useState } from "react";
import { FC, MouseEvent, useEffect, FocusEvent, useState } from "react";
import { observer } from "mobx-react";
import { Check, Info, X } from "lucide-react";
import { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types";
@ -57,7 +57,7 @@ export const EstimatePointUpdate: FC<TEstimatePointUpdate> = observer((props) =>
closeCallBack();
};
const handleUpdate = async (event: FormEvent<HTMLFormElement>) => {
const handleUpdate = async (event: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement, Element>) => {
event.preventDefault();
if (!workspaceSlug || !projectId) return;
@ -134,10 +134,10 @@ export const EstimatePointUpdate: FC<TEstimatePointUpdate> = observer((props) =>
};
return (
<form onSubmit={handleUpdate} className="relative flex items-center gap-2 text-base">
<form className="relative flex items-center gap-2 text-base">
<div
className={cn(
"relative w-full border rounded flex items-center",
"relative w-full border rounded flex items-center my-1",
error ? `border-red-500` : `border-custom-border-200`
)}
>
@ -147,6 +147,7 @@ export const EstimatePointUpdate: FC<TEstimatePointUpdate> = observer((props) =>
onChange={(e) => setEstimateInputValue(e.target.value)}
className="border-none focus:ring-0 focus:border-0 focus:outline-none p-2.5 w-full bg-transparent"
placeholder="Enter estimate point"
onBlur={(e) => !estimateId && handleUpdate(e)}
autoFocus
/>
{error && (
@ -159,21 +160,25 @@ export const EstimatePointUpdate: FC<TEstimatePointUpdate> = observer((props) =>
</>
)}
</div>
<button
type="submit"
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer text-green-500"
disabled={loader}
>
{loader ? <Spinner className="w-4 h-4" /> : <Check size={14} />}
</button>
<button
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer"
onClick={handleClose}
disabled={loader}
>
<X size={14} className="text-custom-text-200" />
</button>
{estimateId && (
<>
<button
type="submit"
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer text-green-500"
disabled={loader}
onClick={handleUpdate}
>
{loader ? <Spinner className="w-4 h-4" /> : <Check size={14} />}
</button>
<button
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer"
onClick={handleClose}
disabled={loader}
>
<X size={14} className="text-custom-text-200" />
</button>
</>
)}
</form>
);
});

View File

@ -10,13 +10,14 @@ import { FavoriteStar } from "@/components/core";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { ModuleQuickActions } from "@/components/modules";
// constants
import { EEstimateSystem } from "@/constants/estimates";
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker";
import { MODULE_STATUS } from "@/constants/module";
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { getDate, renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useEventTracker, useMember, useModule, useUser } from "@/hooks/store";
import { useEventTracker, useMember, useModule, useProjectEstimates, useUser } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
@ -37,6 +38,8 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { getUserDetails } = useMember();
const { captureEvent } = useEventTracker();
const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates();
// derived values
const moduleDetails = getModuleById(moduleId);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
@ -120,14 +123,30 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
if (!moduleDetails) return null;
const moduleTotalIssues =
moduleDetails.backlog_issues +
moduleDetails.unstarted_issues +
moduleDetails.started_issues +
moduleDetails.completed_issues +
moduleDetails.cancelled_issues;
/**
* NOTE: This completion percentage calculation is based on the total issues count.
* when estimates are available and estimate type is points, we should consider the estimate point count
* when estimates are available and estimate type is not points, then by default we consider the issue count
*/
const isEstimateEnabled =
projectId &&
currentActiveEstimateId &&
areEstimateEnabledByProjectId(projectId.toString()) &&
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS;
const completionPercentage = (moduleDetails.completed_issues / moduleTotalIssues) * 100;
const moduleTotalIssues = isEstimateEnabled
? moduleDetails?.total_estimate_points || 0
: moduleDetails.backlog_issues +
moduleDetails.unstarted_issues +
moduleDetails.started_issues +
moduleDetails.completed_issues +
moduleDetails.cancelled_issues;
const moduleCompletedIssues = isEstimateEnabled
? moduleDetails?.completed_estimate_points || 0
: moduleDetails.completed_issues;
const completionPercentage = (moduleCompletedIssues / moduleTotalIssues) * 100;
const endDate = getDate(moduleDetails.target_date);
const startDate = getDate(moduleDetails.start_date);
@ -140,11 +159,11 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
const issueCount = module
? !moduleTotalIssues || moduleTotalIssues === 0
? "0 Issue"
: moduleTotalIssues === moduleDetails.completed_issues
? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}`
: `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues`
: "0 Issue";
? `0 ${isEstimateEnabled ? `Point` : `Issue`}`
: moduleTotalIssues === moduleCompletedIssues
? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? `s` : ``}`
: `${moduleCompletedIssues}/${moduleTotalIssues} ${isEstimateEnabled ? `Points` : `Issues`}`
: `0 ${isEstimateEnabled ? `Point` : `Issue`}`;
const moduleLeadDetails = moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined;

View File

@ -8,8 +8,10 @@ import { CircularProgressIndicator } from "@plane/ui";
// components
import { ListItem } from "@/components/core/list";
import { ModuleListItemAction } from "@/components/modules";
// constants
import { EEstimateSystem } from "@/constants/estimates";
// hooks
import { useModule } from "@/hooks/store";
import { useAppRouter, useModule, useProjectEstimates } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
@ -26,14 +28,28 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
// store hooks
const { getModuleById } = useModule();
const { isMobile } = usePlatformOS();
const { projectId } = useAppRouter();
const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates();
// derived values
const moduleDetails = getModuleById(moduleId);
if (!moduleDetails) return null;
const completionPercentage =
((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100;
/**
* NOTE: This completion percentage calculation is based on the total issues count.
* when estimates are available and estimate type is points, we should consider the estimate point count
* when estimates are available and estimate type is not points, then by default we consider the issue count
*/
const isEstimateEnabled =
projectId &&
currentActiveEstimateId &&
areEstimateEnabledByProjectId(projectId) &&
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS;
const completionPercentage = isEstimateEnabled
? ((moduleDetails?.completed_estimate_points || 0) / (moduleDetails?.total_estimate_points || 0)) * 100
: ((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100;
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);