mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
13 Commits
feat-chang
...
chore-cycl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db06a0d5c6 | ||
|
|
f5a82e5286 | ||
|
|
f5a9dfba7e | ||
|
|
d925b4c529 | ||
|
|
b20f92607e | ||
|
|
cfee078398 | ||
|
|
743c0f8f0a | ||
|
|
820aa7f6d4 | ||
|
|
94e15d693c | ||
|
|
bee30e2fc8 | ||
|
|
0864354d69 | ||
|
|
a1bc348a4e | ||
|
|
9c36be8b89 |
@@ -311,7 +311,7 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
|
||||
if (
|
||||
cycle.end_date is not None
|
||||
and cycle.end_date < timezone.now().date()
|
||||
and cycle.end_date < timezone.now()
|
||||
):
|
||||
if "sort_order" in request_data:
|
||||
# Can only change sort order
|
||||
@@ -537,7 +537,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
if cycle.end_date >= timezone.now().date():
|
||||
if cycle.end_date >= timezone.now():
|
||||
return Response(
|
||||
{"error": "Only completed cycles can be archived"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -1146,7 +1146,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
|
||||
if (
|
||||
new_cycle.end_date is not None
|
||||
and new_cycle.end_date < timezone.now().date()
|
||||
and new_cycle.end_date < timezone.now()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
|
||||
@@ -43,7 +43,10 @@ from .cycle import (
|
||||
CycleSerializer,
|
||||
CycleIssueSerializer,
|
||||
CycleWriteSerializer,
|
||||
CycleUpdatesSerializer,
|
||||
CycleUpdateReactionSerializer,
|
||||
CycleUserPropertiesSerializer,
|
||||
CycleAnalyticsSerializer,
|
||||
)
|
||||
from .asset import FileAssetSerializer
|
||||
from .issue import (
|
||||
|
||||
@@ -7,6 +7,9 @@ from .issue import IssueStateSerializer
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
CycleUpdates,
|
||||
CycleAnalytics,
|
||||
CycleUpdateReaction,
|
||||
CycleUserProperties,
|
||||
)
|
||||
|
||||
@@ -93,6 +96,7 @@ class CycleIssueSerializer(BaseSerializer):
|
||||
"cycle",
|
||||
]
|
||||
|
||||
|
||||
class CycleUserPropertiesSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = CycleUserProperties
|
||||
@@ -102,3 +106,34 @@ class CycleUserPropertiesSerializer(BaseSerializer):
|
||||
"project",
|
||||
"cycle" "user",
|
||||
]
|
||||
|
||||
|
||||
class CycleAnalyticsSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = CycleAnalytics
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class CycleUpdatesSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = CycleUpdates
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"cycle",
|
||||
"issue",
|
||||
]
|
||||
|
||||
|
||||
class CycleUpdateReactionSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = CycleUpdateReaction
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"cycle",
|
||||
"issue",
|
||||
"cycle_update",
|
||||
]
|
||||
|
||||
@@ -9,8 +9,11 @@ from plane.app.views import (
|
||||
CycleProgressEndpoint,
|
||||
CycleAnalyticsEndpoint,
|
||||
TransferCycleIssueEndpoint,
|
||||
CycleIssueStateAnalyticsEndpoint,
|
||||
CycleUserPropertiesEndpoint,
|
||||
CycleArchiveUnarchiveEndpoint,
|
||||
CycleUpdatesViewSet,
|
||||
CycleUpdatesReactionViewSet,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,4 +121,49 @@ urlpatterns = [
|
||||
CycleAnalyticsEndpoint.as_view(),
|
||||
name="project-cycle",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-progress/",
|
||||
CycleIssueStateAnalyticsEndpoint.as_view(),
|
||||
name="project-cycle-progress",
|
||||
),
|
||||
# Cycle Updates
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/updates/",
|
||||
CycleUpdatesViewSet.as_view({
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}),
|
||||
name="cycle-updates",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/updates/<uuid:pk>/",
|
||||
CycleUpdatesViewSet.as_view({
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}),
|
||||
name="cycle-updates",
|
||||
),
|
||||
# End Cycle Updates
|
||||
# Updates Reactions
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/updates/<uuid:update_id>/reactions/",
|
||||
CycleUpdatesReactionViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-cycle-update-reactions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/updates/<uuid:update_id>/reactions/<str:reaction_code>/",
|
||||
CycleUpdatesReactionViewSet.as_view(
|
||||
{
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="project-cycle-update-reactions",
|
||||
),
|
||||
## End Updates Reactions
|
||||
]
|
||||
|
||||
@@ -100,10 +100,10 @@ from .cycle.base import (
|
||||
TransferCycleIssueEndpoint,
|
||||
CycleAnalyticsEndpoint,
|
||||
CycleProgressEndpoint,
|
||||
CycleIssueStateAnalyticsEndpoint,
|
||||
)
|
||||
from .cycle.issue import (
|
||||
CycleIssueViewSet,
|
||||
)
|
||||
from .cycle.updates import CycleUpdatesViewSet, CycleUpdatesReactionViewSet
|
||||
from .cycle.issue import CycleIssueViewSet
|
||||
from .cycle.archive import (
|
||||
CycleArchiveUnarchiveEndpoint,
|
||||
)
|
||||
|
||||
@@ -604,7 +604,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
|
||||
if cycle.end_date >= timezone.now().date():
|
||||
if cycle.end_date >= timezone.now():
|
||||
return Response(
|
||||
{"error": "Only completed cycles can be archived"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -31,6 +31,7 @@ from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.app.serializers import (
|
||||
CycleSerializer,
|
||||
CycleUserPropertiesSerializer,
|
||||
CycleAnalyticsSerializer,
|
||||
CycleWriteSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
@@ -44,6 +45,8 @@ from plane.db.models import (
|
||||
User,
|
||||
Project,
|
||||
ProjectMember,
|
||||
CycleAnalytics,
|
||||
CycleIssueStateProgress,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
@@ -308,7 +311,7 @@ class CycleViewSet(BaseViewSet):
|
||||
|
||||
if (
|
||||
cycle.end_date is not None
|
||||
and cycle.end_date < timezone.now().date()
|
||||
and cycle.end_date < timezone.now()
|
||||
):
|
||||
if "sort_order" in request_data:
|
||||
# Can only change sort order for a completed cycle``
|
||||
@@ -925,7 +928,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
|
||||
if (
|
||||
new_cycle.end_date is not None
|
||||
and new_cycle.end_date < timezone.now().date()
|
||||
and new_cycle.end_date < timezone.now()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
@@ -958,6 +961,37 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
updated_cycles, ["cycle_id"], batch_size=100
|
||||
)
|
||||
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
CycleIssueStateProgress.objects.bulk_create(
|
||||
[
|
||||
CycleIssueStateProgress(
|
||||
cycle_id=new_cycle_id,
|
||||
state_id=cycle_issue.issue.state_id,
|
||||
issue_id=cycle_issue.issue_id,
|
||||
state_group=cycle_issue.issue.state.group,
|
||||
type="ADDED",
|
||||
estimate_id=cycle_issue.issue.estimate_point_id,
|
||||
estimate_value=(
|
||||
cycle_issue.issue.estimate_point.value
|
||||
if estimate_type
|
||||
else None
|
||||
),
|
||||
project_id=project_id,
|
||||
workspace_id=cycle_issue.workspace_id,
|
||||
created_by_id=request.user.id,
|
||||
updated_by_id=request.user.id,
|
||||
)
|
||||
for cycle_issue in cycle_issues
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.created",
|
||||
@@ -1148,6 +1182,7 @@ class CycleProgressEndpoint(BaseAPIView):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
@@ -1367,3 +1402,19 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class CycleIssueStateAnalyticsEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, cycle_id):
|
||||
cycle_state_progress = CycleAnalytics.objects.filter(
|
||||
cycle_id=cycle_id,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
return Response(
|
||||
CycleAnalyticsSerializer(cycle_state_progress, many=True).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -24,6 +24,8 @@ from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
CycleIssueStateProgress,
|
||||
Project,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -246,10 +248,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
)
|
||||
|
||||
if (
|
||||
cycle.end_date is not None
|
||||
and cycle.end_date < timezone.now().date()
|
||||
):
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
return Response(
|
||||
{
|
||||
"error": "The Cycle has already been completed so no new issues can be added"
|
||||
@@ -268,6 +267,12 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
]
|
||||
new_issues = list(set(issues) - set(existing_issues))
|
||||
|
||||
# Fetch issue details
|
||||
issue_objects = Issue.objects.filter(id__in=issues).annotate(
|
||||
cycle_id=F("issue_cycle__cycle_id")
|
||||
)
|
||||
issue_dict = {str(issue.id): issue for issue in issue_objects}
|
||||
|
||||
# New issues to create
|
||||
created_records = CycleIssue.objects.bulk_create(
|
||||
[
|
||||
@@ -284,6 +289,60 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
CycleIssueStateProgress.objects.bulk_create(
|
||||
[
|
||||
CycleIssueStateProgress(
|
||||
cycle_id=cycle_id,
|
||||
state_id=str(issue_dict[issue_id].state_id),
|
||||
issue_id=issue_id,
|
||||
state_group=issue_dict[issue_id].state.group,
|
||||
type="ADDED",
|
||||
estimate_id=issue_dict[issue_id].estimate_point_id,
|
||||
estimate_value=(
|
||||
issue_dict[issue_id].estimate_point.value
|
||||
if estimate_type
|
||||
else None
|
||||
),
|
||||
project_id=project_id,
|
||||
workspace_id=cycle.workspace_id,
|
||||
created_by_id=request.user.id,
|
||||
updated_by_id=request.user.id,
|
||||
)
|
||||
for issue_id in issues
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
CycleIssueStateProgress.objects.bulk_create(
|
||||
[
|
||||
CycleIssueStateProgress(
|
||||
cycle_id=issue_dict[issue_id].cycle_id,
|
||||
state_id=str(issue_dict[issue_id].state_id),
|
||||
issue_id=issue_id,
|
||||
state_group=issue_dict[issue_id].state.group,
|
||||
type="REMOVED",
|
||||
estimate_id=issue_dict[issue_id].estimate_point_id,
|
||||
estimate_value=(
|
||||
issue_dict[issue_id].estimate_point.value
|
||||
if estimate_type
|
||||
else None
|
||||
),
|
||||
project_id=project_id,
|
||||
workspace_id=cycle.workspace_id,
|
||||
created_by_id=request.user.id,
|
||||
updated_by_id=request.user.id,
|
||||
)
|
||||
for issue_id in existing_issues
|
||||
]
|
||||
)
|
||||
|
||||
# Updated Issues
|
||||
updated_records = []
|
||||
update_cycle_issue_activity = []
|
||||
@@ -336,6 +395,28 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
project_id=project_id,
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
CycleIssueStateProgress.objects.create(
|
||||
cycle_id=cycle_id,
|
||||
state_id=issue.state_id,
|
||||
issue_id=issue_id,
|
||||
state_group=issue.state.group,
|
||||
type="REMOVED",
|
||||
estimate_id=issue.estimate_point_id,
|
||||
estimate_value=(
|
||||
issue.estimate_point.value if estimate_type else None
|
||||
),
|
||||
project_id=project_id,
|
||||
created_by_id=request.user.id,
|
||||
updated_by_id=request.user.id,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
|
||||
182
apiserver/plane/app/views/cycle/updates.py
Normal file
182
apiserver/plane/app/views/cycle/updates.py
Normal file
@@ -0,0 +1,182 @@
|
||||
# Django imports
|
||||
from django.db.models import Subquery, OuterRef, Sum
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
from plane.app.serializers import (
|
||||
CycleUpdatesSerializer,
|
||||
CycleUpdateReactionSerializer,
|
||||
)
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.db.models import (
|
||||
CycleUpdates,
|
||||
CycleUpdateReaction,
|
||||
CycleIssueStateProgress,
|
||||
)
|
||||
|
||||
|
||||
class CycleUpdatesViewSet(BaseViewSet):
|
||||
serializer_class = CycleUpdatesSerializer
|
||||
model = CycleUpdates
|
||||
|
||||
filterset_fields = [
|
||||
"issue__id",
|
||||
"workspace__id",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(cycle_id=self.kwargs.get("cycle_id"))
|
||||
.filter(parent__isnull=True)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
)
|
||||
.select_related("workspace", "project", "cycle")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@allow_permission(
|
||||
[
|
||||
ROLE.ADMIN,
|
||||
ROLE.MEMBER,
|
||||
ROLE.GUEST,
|
||||
]
|
||||
)
|
||||
def create(self, request, slug, project_id, cycle_id):
|
||||
cycle_issues = CycleIssueStateProgress.objects.filter(
|
||||
id=Subquery(
|
||||
CycleIssueStateProgress.objects.filter(
|
||||
cycle_id=cycle_id,
|
||||
issue=OuterRef("issue"),
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.values("id")[:1]
|
||||
),
|
||||
type__in=["ADDED", "UPDATED"],
|
||||
)
|
||||
total_issues = cycle_issues.count()
|
||||
total_estimate_points = (
|
||||
cycle_issues.aggregate(
|
||||
total_estimate_points=Sum("estimate_value")
|
||||
)["total_estimate_points"]
|
||||
or 0
|
||||
)
|
||||
completed_issues = cycle_issues.filter(state_group="completed").count()
|
||||
completed_estimate_points = cycle_issues.filter(
|
||||
state_group="completed"
|
||||
).aggregate(total_estimate_points=Sum("estimate_value"))[
|
||||
"total_estimate_points"
|
||||
]
|
||||
|
||||
serializer = CycleUpdatesSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id,
|
||||
cycle_id=cycle_id,
|
||||
total_issues=total_issues,
|
||||
total_estimate_points=total_estimate_points,
|
||||
completed_issues=completed_issues,
|
||||
completed_estimate_points=completed_estimate_points,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN],
|
||||
creator=True,
|
||||
model=CycleUpdates,
|
||||
)
|
||||
def partial_update(self, request, slug, project_id, cycle_id, pk):
|
||||
cycle_update = CycleUpdates.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
cycle_id=cycle_id,
|
||||
pk=pk,
|
||||
)
|
||||
serializer = CycleUpdatesSerializer(
|
||||
cycle_update, data=request.data, partial=True
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN], creator=True, model=CycleUpdates
|
||||
)
|
||||
def destroy(self, request, slug, project_id, cycle_id, pk):
|
||||
cycle_update = CycleUpdates.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
cycle_id=cycle_id,
|
||||
pk=pk,
|
||||
)
|
||||
cycle_update.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class CycleUpdatesReactionViewSet(BaseViewSet):
|
||||
serializer_class = CycleUpdateReactionSerializer
|
||||
model = CycleUpdateReaction
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(update_id=self.kwargs.get("update_id"))
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@allow_permission(
|
||||
[
|
||||
ROLE.ADMIN,
|
||||
ROLE.MEMBER,
|
||||
ROLE.GUEST,
|
||||
]
|
||||
)
|
||||
def create(self, request, slug, project_id, update_id):
|
||||
serializer = CycleUpdateReactionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id,
|
||||
actor_id=request.user.id,
|
||||
update_id=update_id,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(
|
||||
[
|
||||
ROLE.ADMIN,
|
||||
ROLE.MEMBER,
|
||||
ROLE.GUEST,
|
||||
]
|
||||
)
|
||||
def destroy(self, request, slug, project_id, update_id, reaction_code):
|
||||
cycle_update_reaction = CycleUpdateReaction.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
update_id=update_id,
|
||||
reaction=reaction_code,
|
||||
actor=request.user,
|
||||
)
|
||||
cycle_update_reaction.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -40,8 +40,6 @@ from plane.db.models import (
|
||||
IssueLink,
|
||||
IssueRelation,
|
||||
Project,
|
||||
ProjectMember,
|
||||
User,
|
||||
Widget,
|
||||
WorkspaceMember,
|
||||
)
|
||||
|
||||
@@ -167,10 +167,10 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
inbox_id = Inbox.objects.get(
|
||||
inbox_id = Inbox.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
).first()
|
||||
project = Project.objects.get(pk=project_id)
|
||||
filters = issue_filters(request.GET, "GET", "issue__")
|
||||
inbox_issue = (
|
||||
InboxIssue.objects.filter(
|
||||
@@ -527,9 +527,9 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
model=Issue,
|
||||
)
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
inbox_id = Inbox.objects.get(
|
||||
inbox_id = Inbox.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
).first()
|
||||
project = Project.objects.get(pk=project_id)
|
||||
inbox_issue = (
|
||||
InboxIssue.objects.select_related("issue")
|
||||
|
||||
@@ -42,6 +42,7 @@ from plane.db.models import (
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
ProjectMember,
|
||||
CycleIssueStateProgress,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -601,6 +602,12 @@ class IssueViewSet(BaseViewSet):
|
||||
current_instance = json.dumps(
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
||||
serializer = IssueCreateSerializer(
|
||||
@@ -619,6 +626,23 @@ class IssueViewSet(BaseViewSet):
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
if issue.cycle_id and request.data.get("state_id"):
|
||||
CycleIssueStateProgress.objects.create(
|
||||
cycle_id=issue.cycle_id,
|
||||
state_id=issue.state_id,
|
||||
issue_id=issue.id,
|
||||
state_group=issue.state.group,
|
||||
type="UPDATED",
|
||||
estimate_id=issue.estimate_point_id,
|
||||
estimate_value=(
|
||||
issue.estimate_point.value if estimate_type else None
|
||||
),
|
||||
project_id=project_id,
|
||||
workspace_id=issue.workspace_id,
|
||||
created_by_id=request.user.id,
|
||||
updated_by_id=request.user.id,
|
||||
)
|
||||
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(serializer.data.get("id", None)),
|
||||
|
||||
@@ -512,8 +512,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
present_cycle = CycleIssue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
cycle__start_date__lt=timezone.now().date(),
|
||||
cycle__end_date__gt=timezone.now().date(),
|
||||
cycle__start_date__lt=timezone.now(),
|
||||
cycle__end_date__gt=timezone.now(),
|
||||
issue__assignees__in=[
|
||||
user_id,
|
||||
],
|
||||
|
||||
98
apiserver/plane/bgtasks/cycle_issue_state_progress_task.py
Normal file
98
apiserver/plane/bgtasks/cycle_issue_state_progress_task.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# Django imports
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
from django.db.models import Subquery, OuterRef
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from plane.db.models import Cycle, CycleIssueStateProgress, CycleAnalytics
|
||||
|
||||
|
||||
@shared_task
|
||||
def track_cycle_issue_state_progress(
|
||||
current_date=timezone.now().date() - timezone.timedelta(days=1),
|
||||
):
|
||||
|
||||
active_cycles = Cycle.objects.filter(
|
||||
start_date__lte=timezone.now(), end_date__gte=timezone.now()
|
||||
).values_list("id", "project_id", "workspace_id")
|
||||
|
||||
analytics_records = []
|
||||
|
||||
for cycle_id, project_id, workspace_id in active_cycles:
|
||||
cycle_issues = CycleIssueStateProgress.objects.filter(
|
||||
id=Subquery(
|
||||
CycleIssueStateProgress.objects.filter(
|
||||
cycle_id=cycle_id,
|
||||
issue=OuterRef("issue"),
|
||||
created_at__date__lte=current_date,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.values("id")[:1]
|
||||
),
|
||||
type__in=["ADDED", "UPDATED"],
|
||||
)
|
||||
|
||||
total_issues = cycle_issues.count()
|
||||
total_estimate_points = (
|
||||
cycle_issues.aggregate(
|
||||
total_estimate_points=Sum("estimate_value")
|
||||
)["total_estimate_points"]
|
||||
or 0
|
||||
)
|
||||
|
||||
state_groups = [
|
||||
"backlog",
|
||||
"unstarted",
|
||||
"started",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]
|
||||
state_data = {
|
||||
group: {
|
||||
"count": cycle_issues.filter(state_group=group).count(),
|
||||
"estimate_points": cycle_issues.filter(
|
||||
state_group=group
|
||||
).aggregate(total_estimate_points=Sum("estimate_value"))[
|
||||
"total_estimate_points"
|
||||
]
|
||||
or 0,
|
||||
}
|
||||
for group in state_groups
|
||||
}
|
||||
|
||||
# Prepare analytics record for bulk insert
|
||||
analytics_records.append(
|
||||
CycleAnalytics(
|
||||
cycle_id=cycle_id,
|
||||
date=current_date,
|
||||
total_issues=total_issues,
|
||||
total_estimate_points=total_estimate_points,
|
||||
backlog_issues=state_data["backlog"]["count"],
|
||||
unstarted_issues=state_data["unstarted"]["count"],
|
||||
started_issues=state_data["started"]["count"],
|
||||
completed_issues=state_data["completed"]["count"],
|
||||
cancelled_issues=state_data["cancelled"]["count"],
|
||||
backlog_estimate_points=state_data["backlog"][
|
||||
"estimate_points"
|
||||
],
|
||||
unstarted_estimate_points=state_data["unstarted"][
|
||||
"estimate_points"
|
||||
],
|
||||
started_estimate_points=state_data["started"][
|
||||
"estimate_points"
|
||||
],
|
||||
completed_estimate_points=state_data["completed"][
|
||||
"estimate_points"
|
||||
],
|
||||
cancelled_estimate_points=state_data["cancelled"][
|
||||
"estimate_points"
|
||||
],
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk create the records at once
|
||||
if analytics_records:
|
||||
CycleAnalytics.objects.bulk_create(analytics_records)
|
||||
@@ -40,6 +40,10 @@ app.conf.beat_schedule = {
|
||||
"task": "plane.bgtasks.deletion_task.hard_delete",
|
||||
"schedule": crontab(hour=0, minute=0),
|
||||
},
|
||||
"track-cycle-issue-state-progress": {
|
||||
"task": "plane.bgtasks.cycle_issue_state_progress_task.track_cycle_issue_state_progress",
|
||||
"schedule": crontab(hour=7, minute=32),
|
||||
},
|
||||
}
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,16 @@ from .analytic import AnalyticView
|
||||
from .api import APIActivityLog, APIToken
|
||||
from .asset import FileAsset
|
||||
from .base import BaseModel
|
||||
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
|
||||
from .cycle import (
|
||||
Cycle,
|
||||
CycleFavorite,
|
||||
CycleIssue,
|
||||
CycleUserProperties,
|
||||
CycleAnalytics,
|
||||
CycleUpdates,
|
||||
CycleUpdateReaction,
|
||||
CycleIssueStateProgress,
|
||||
)
|
||||
from .dashboard import Dashboard, DashboardWidget, Widget
|
||||
from .deploy_board import DeployBoard
|
||||
from .estimate import Estimate, EstimatePoint
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Python Imports
|
||||
import pytz
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
@@ -55,10 +58,12 @@ class Cycle(ProjectBaseModel):
|
||||
description = models.TextField(
|
||||
verbose_name="Cycle Description", blank=True
|
||||
)
|
||||
start_date = models.DateField(
|
||||
start_date = models.DateTimeField(
|
||||
verbose_name="Start Date", blank=True, null=True
|
||||
)
|
||||
end_date = models.DateField(verbose_name="End Date", blank=True, null=True)
|
||||
end_date = models.DateTimeField(
|
||||
verbose_name="End Date", blank=True, null=True
|
||||
)
|
||||
owned_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
@@ -71,6 +76,11 @@ class Cycle(ProjectBaseModel):
|
||||
progress_snapshot = models.JSONField(default=dict)
|
||||
archived_at = models.DateTimeField(null=True)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
# timezone
|
||||
USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
|
||||
user_timezone = models.CharField(
|
||||
max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Cycle"
|
||||
@@ -176,3 +186,147 @@ class CycleUserProperties(ProjectBaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.cycle.name} {self.user.email}"
|
||||
|
||||
|
||||
class TypeEnum(models.TextChoices):
|
||||
ADDED = "ADDED", "Added"
|
||||
UPDATED = "UPDATED", "Updated"
|
||||
REMOVED = "REMOVED", "Removed"
|
||||
# TRANSFER = "TRANSFER", "Transfer"
|
||||
|
||||
|
||||
class CycleIssueStateProgress(ProjectBaseModel):
|
||||
cycle = models.ForeignKey(
|
||||
"db.Cycle",
|
||||
on_delete=models.DO_NOTHING,
|
||||
related_name="cycle_issue_state_progress",
|
||||
)
|
||||
state = models.ForeignKey(
|
||||
"db.State",
|
||||
on_delete=models.DO_NOTHING,
|
||||
related_name="cycle_issue_state_progress",
|
||||
)
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue",
|
||||
on_delete=models.DO_NOTHING,
|
||||
related_name="cycle_issue_state_progress",
|
||||
)
|
||||
state_group = models.CharField(max_length=255)
|
||||
type = models.CharField(
|
||||
max_length=30,
|
||||
choices=TypeEnum.choices,
|
||||
)
|
||||
estimate_id = models.UUIDField(null=True)
|
||||
estimate_value = models.FloatField(null=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Cycle Issue State Progress"
|
||||
verbose_name_plural = "Cycle Issue State Progress"
|
||||
db_table = "cycle_issue_state_progress"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.cycle.name} {self.issue.name}"
|
||||
|
||||
|
||||
class CycleAnalytics(ProjectBaseModel):
|
||||
cycle = models.ForeignKey(
|
||||
"db.Cycle", on_delete=models.CASCADE, related_name="cycle_analytics"
|
||||
)
|
||||
date = models.DateField()
|
||||
data = models.JSONField(default=dict)
|
||||
|
||||
total_issues = models.FloatField(default=0)
|
||||
total_estimate_points = models.FloatField(default=0)
|
||||
|
||||
# state group wise distribution
|
||||
backlog_issues = models.FloatField(default=0)
|
||||
unstarted_issues = models.FloatField(default=0)
|
||||
started_issues = models.FloatField(default=0)
|
||||
completed_issues = models.FloatField(default=0)
|
||||
cancelled_issues = models.FloatField(default=0)
|
||||
|
||||
backlog_estimate_points = models.FloatField(default=0)
|
||||
unstarted_estimate_points = models.FloatField(default=0)
|
||||
started_estimate_points = models.FloatField(default=0)
|
||||
completed_estimate_points = models.FloatField(default=0)
|
||||
cancelled_estimate_points = models.FloatField(default=0)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["cycle", "date"]
|
||||
verbose_name = "Cycle Analytics"
|
||||
verbose_name_plural = "Cycle Analytics"
|
||||
db_table = "cycle_analytics"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} <{self.cycle.name}>"
|
||||
|
||||
|
||||
class UpdatesEnum(models.TextChoices):
|
||||
ONTRACK = "ONTRACK", "On Track"
|
||||
OFFTRACK = "OFFTRACK", "Off Track"
|
||||
AT_RISK = "AT_RISK", "At Risk"
|
||||
STARTED = "STARTED", "Started"
|
||||
SCOPE_INCREASED = "SCOPE_INCREASED", "Scope Increased"
|
||||
SCOPE_DECREASED = "SCOPE_DECREASED", "Scope Decreased"
|
||||
|
||||
|
||||
class CycleUpdates(ProjectBaseModel):
|
||||
cycle = models.ForeignKey(
|
||||
"db.Cycle", on_delete=models.CASCADE, related_name="cycle_updates"
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
status = models.CharField(
|
||||
max_length=30,
|
||||
choices=UpdatesEnum.choices,
|
||||
)
|
||||
parent = models.ForeignKey(
|
||||
"db.CycleUpdates",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="cycle_updates",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
completed_issues = models.FloatField(default=0)
|
||||
total_issues = models.FloatField(default=0)
|
||||
total_estimate_points = models.FloatField(default=0)
|
||||
completed_estimate_points = models.FloatField(default=0)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Cycle Updates"
|
||||
verbose_name_plural = "Cycle Updates"
|
||||
db_table = "cycle_updates"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.cycle.name} {self.status}"
|
||||
|
||||
|
||||
class CycleUpdateReaction(ProjectBaseModel):
|
||||
cycle = models.ForeignKey(
|
||||
"db.Cycle",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="cycle_update_reactions",
|
||||
)
|
||||
update = models.ForeignKey(
|
||||
"db.CycleUpdates",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="cycle_update_reactions",
|
||||
)
|
||||
reaction = models.CharField(max_length=20)
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="cycle_update_reactions",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Cycle Update Reaction"
|
||||
verbose_name_plural = "Cycle Update Reactions"
|
||||
db_table = "cycle_update_reactions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.actor.email} <{self.cycle.name}>"
|
||||
|
||||
@@ -153,7 +153,7 @@ class Issue(ProjectBaseModel):
|
||||
through_fields=("issue", "assignee"),
|
||||
)
|
||||
sequence_id = models.IntegerField(
|
||||
default=1, verbose_name="Issue Sequence ID"
|
||||
default=1, verbose_name="Issue Sequence ID", null=True, blank=True
|
||||
)
|
||||
labels = models.ManyToManyField(
|
||||
"db.Label", blank=True, related_name="labels", through="IssueLabel"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Python imports
|
||||
import pytz
|
||||
from uuid import uuid4
|
||||
|
||||
# Django imports
|
||||
@@ -119,6 +120,11 @@ class Project(BaseModel):
|
||||
related_name="default_state",
|
||||
)
|
||||
archived_at = models.DateTimeField(null=True)
|
||||
# timezone
|
||||
USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
|
||||
user_timezone = models.CharField(
|
||||
max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the project"""
|
||||
|
||||
@@ -279,6 +279,7 @@ CELERY_IMPORTS = (
|
||||
"plane.bgtasks.file_asset_task",
|
||||
"plane.bgtasks.email_notification_task",
|
||||
"plane.bgtasks.api_logs_task",
|
||||
"plane.bgtasks.cycle_issue_state_progress_task",
|
||||
# management tasks
|
||||
"plane.bgtasks.dummy_data_task",
|
||||
)
|
||||
|
||||
@@ -163,7 +163,7 @@ def burndown_plot(
|
||||
if queryset.end_date and queryset.start_date:
|
||||
# Get all dates between the two dates
|
||||
date_range = [
|
||||
queryset.start_date + timedelta(days=x)
|
||||
(queryset.start_date + timedelta(days=x)).date()
|
||||
for x in range(
|
||||
(queryset.end_date - queryset.start_date).days + 1
|
||||
)
|
||||
@@ -203,7 +203,7 @@ def burndown_plot(
|
||||
if module_id:
|
||||
# Get all dates between the two dates
|
||||
date_range = [
|
||||
queryset.start_date + timedelta(days=x)
|
||||
(queryset.start_date + timedelta(days=x)).date()
|
||||
for x in range(
|
||||
(queryset.target_date - queryset.start_date).days + 1
|
||||
)
|
||||
@@ -254,6 +254,7 @@ def burndown_plot(
|
||||
chart_data[str(date)] = cumulative_pending_issues
|
||||
else:
|
||||
for date in date_range:
|
||||
print(date, "date")
|
||||
cumulative_pending_issues = total_issues
|
||||
total_completed = 0
|
||||
total_completed = sum(
|
||||
|
||||
@@ -42,5 +42,8 @@
|
||||
"@types/react": "18.2.48"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"name": "plane"
|
||||
"name": "plane",
|
||||
"dependencies": {
|
||||
"recharts": "^2.12.7"
|
||||
}
|
||||
}
|
||||
|
||||
21
packages/types/src/cycle/cycle.d.ts
vendored
21
packages/types/src/cycle/cycle.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import type {TIssue, IIssueFilterOptions} from "@plane/types";
|
||||
import type { TIssue, IIssueFilterOptions } from "@plane/types";
|
||||
|
||||
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
|
||||
|
||||
@@ -43,6 +43,19 @@ export type TCycleEstimateDistribution = {
|
||||
completion_chart: TCycleCompletionChartDistribution;
|
||||
labels: (TCycleLabelsDistribution & TCycleEstimateDistributionBase)[];
|
||||
};
|
||||
export type TCycleProgress = {
|
||||
date: string;
|
||||
started: number;
|
||||
actual: number;
|
||||
pending: number;
|
||||
ideal: number;
|
||||
scope: number;
|
||||
completed: number;
|
||||
actual: number;
|
||||
unstarted: number;
|
||||
backlog: number;
|
||||
cancelled: number;
|
||||
};
|
||||
|
||||
export type TProgressSnapshot = {
|
||||
total_issues: number;
|
||||
@@ -90,6 +103,7 @@ export interface ICycle extends TProgressSnapshot {
|
||||
};
|
||||
workspace_id: string;
|
||||
project_detail: IProjectDetails;
|
||||
progress: any[];
|
||||
}
|
||||
|
||||
export interface CycleIssueResponse {
|
||||
@@ -107,7 +121,7 @@ export interface CycleIssueResponse {
|
||||
}
|
||||
|
||||
export type SelectCycleType =
|
||||
| (ICycle & {actionType: "edit" | "delete" | "create-issue"})
|
||||
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
|
||||
| undefined;
|
||||
|
||||
export type CycleDateCheckData = {
|
||||
@@ -116,4 +130,5 @@ export type CycleDateCheckData = {
|
||||
cycle_id?: string;
|
||||
};
|
||||
|
||||
export type TCyclePlotType = "burndown" | "points";
|
||||
export type TCycleEstimateType = "issues" | "points";
|
||||
export type TCyclePlotType = "burndown" | "burnup";
|
||||
|
||||
22
packages/ui/src/icons/done-icon.tsx
Normal file
22
packages/ui/src/icons/done-icon.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const DoneState: React.FC<ISvgIcons> = ({ width = "10", height = "11", className, color }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 10 11"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="5" cy="5.5" r="4.4" stroke="#15A34A" stroke-width="1.2" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2.5 5.59375L3.82582 6.91957L4.26777 6.47763L2.94194 5.15181L2.5 5.59375ZM4.26777 7.36152L7.36136 4.26793L6.91942 3.82599L3.82583 6.91958L4.26777 7.36152Z"
|
||||
fill="#15A34A"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
17
packages/ui/src/icons/in-progress-icon.tsx
Normal file
17
packages/ui/src/icons/in-progress-icon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const InProgressState: React.FC<ISvgIcons> = ({ width = "10", height = "11", className, color }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 12 13"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="6" cy="6.5" r="4.4" stroke="#EA8900" stroke-width="1.2" />
|
||||
<circle cx="6" cy="6.5" r="2.4" stroke="#EA8900" stroke-width="1.2" stroke-dasharray="4 4" />
|
||||
</svg>
|
||||
);
|
||||
@@ -28,3 +28,6 @@ export * from "./dropdown-icon";
|
||||
export * from "./intake";
|
||||
export * from "./user-activity-icon";
|
||||
export * from "./favorite-folder-icon";
|
||||
export * from "./planned-icon";
|
||||
export * from "./in-progress-icon";
|
||||
export * from "./done-icon";
|
||||
|
||||
40
packages/ui/src/icons/planned-icon.tsx
Normal file
40
packages/ui/src/icons/planned-icon.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const PlannedState: React.FC<ISvgIcons> = ({ width = "10", height = "11", className, color }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 12 13"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g clip-path="url(#clip0_3180_28635)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M7.11853 4.7C7.20073 4.88749 7.38342 5.01866 7.59656 5.02037C7.88848 5.02271 8.12698 4.7813 8.12925 4.48116L8.13344 3.92962C8.1348 3.74982 8.04958 3.58096 7.90581 3.47859C7.76203 3.37623 7.57832 3.3536 7.41509 3.41815L3.97959 4.77682C3.77547 4.85755 3.64077 5.05919 3.64077 5.28406L3.64077 9.0883C3.64077 9.27834 3.73732 9.45458 3.8954 9.55308C4.05347 9.65157 4.25011 9.65802 4.41396 9.57008L4.90523 9.30643C5.16402 9.16754 5.26431 8.83925 5.12922 8.57317C5.04115 8.39971 4.8748 8.29551 4.69795 8.28247L4.69795 5.65729L7.11853 4.7Z"
|
||||
fill="#455068"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5.00428 3.06914C5.08648 3.25663 5.26916 3.3878 5.4823 3.38951C5.77422 3.39185 6.01272 3.15044 6.015 2.8503L6.01918 2.29876C6.02054 2.11896 5.93532 1.9501 5.79155 1.84774C5.64777 1.74537 5.46406 1.72274 5.30084 1.78729L1.86534 3.14597C1.66121 3.22669 1.52652 3.42834 1.52652 3.6532L1.52652 7.45745C1.52652 7.64749 1.62307 7.82372 1.78114 7.92222C1.93922 8.02071 2.13585 8.02716 2.29971 7.93922L2.79097 7.67557C3.04977 7.53668 3.15005 7.20839 3.01496 6.94231C2.92689 6.76885 2.76054 6.66465 2.5837 6.65161L2.5837 4.02643L5.00428 3.06914Z"
|
||||
fill="#455068"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M10.473 9.34799C10.4728 9.57269 10.3382 9.77413 10.1342 9.85482L6.70129 11.2129C6.53874 11.2772 6.35582 11.255 6.21225 11.1536C6.06867 11.0523 5.98288 10.8847 5.98288 10.7056L5.98288 6.90139C5.98288 6.67653 6.11757 6.47489 6.3217 6.39416L9.7572 5.03548C9.91981 4.97118 10.1028 4.99338 10.2464 5.09484C10.3899 5.19629 10.4757 5.36397 10.4756 5.5431L10.473 9.34799ZM9.41784 6.33426L7.04006 7.27463L7.04006 9.91423L9.41605 8.97431L9.41784 6.33426Z"
|
||||
fill="#455068"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3180_28635">
|
||||
<rect width="12" height="12" fill="white" transform="translate(0 0.5)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
235
web/core/components/cycles/active-cycle/cycle-chart/chart.tsx
Normal file
235
web/core/components/cycles/active-cycle/cycle-chart/chart.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Area,
|
||||
Line,
|
||||
ComposedChart,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
ReferenceLine,
|
||||
Label,
|
||||
Tooltip,
|
||||
ReferenceArea,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
|
||||
import { chartHelper, maxScope } from "./helper";
|
||||
import CustomTooltip from "./tooltip";
|
||||
import { CustomizedXAxisTicks, CustomizedYAxisTicks } from "./ticks";
|
||||
import { renderScopeLabel, renderYAxisLabel } from "./labels";
|
||||
import { getToday } from "@/helpers/date-time.helper";
|
||||
import { ICycle } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
areaToHighlight: string;
|
||||
cycle: ICycle;
|
||||
data: any;
|
||||
};
|
||||
|
||||
export const ActiveCycleChart = (props: Props) => {
|
||||
const { areaToHighlight, data, cycle } = props;
|
||||
let endDate: Date | string = new Date(cycle.end_date!);
|
||||
|
||||
const { diffGradient, dataWithRange } = chartHelper(data, endDate);
|
||||
endDate = endDate.toISOString().split("T")[0];
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%">
|
||||
<ComposedChart
|
||||
data={dataWithRange}
|
||||
margin={{
|
||||
top: 30,
|
||||
right: 0,
|
||||
bottom: 70,
|
||||
left: 20,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid stroke="#f5f5f5" vertical={false} />
|
||||
{/* Area fills */}
|
||||
<defs>
|
||||
{/* Time left */}
|
||||
<pattern
|
||||
id="fillTimeLeft"
|
||||
patternUnits="userSpaceOnUse"
|
||||
width="4"
|
||||
height="8"
|
||||
patternTransform="rotate(-45 2 2)"
|
||||
>
|
||||
<path d="M -1,2 l 6,0" stroke="#E0EAFF" stroke-width=".5" />
|
||||
</pattern>
|
||||
|
||||
{/* Beyond Time */}
|
||||
<pattern
|
||||
id="fillTimeBeyond"
|
||||
patternUnits="userSpaceOnUse"
|
||||
width="4"
|
||||
height="8"
|
||||
patternTransform="rotate(-45 2 2)"
|
||||
>
|
||||
<path d="M -1,2 l 6,0" stroke="#FF9999" stroke-width=".5" />
|
||||
</pattern>
|
||||
|
||||
{/* actual */}
|
||||
<linearGradient id="fillPending" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#26D950" stopOpacity={1} />
|
||||
<stop offset="95%" stopColor="#26D950" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
|
||||
{/* Started */}
|
||||
<linearGradient id="fillStarted" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#FFAA33" stopOpacity={1} />
|
||||
<stop offset="95%" stopColor="#FFAA33" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
|
||||
{/* Scope */}
|
||||
<linearGradient id="fillScope" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="rgba(var(--color-primary-100))" stopOpacity={1} />
|
||||
<stop offset="95%" stopColor="rgba(var(--color-primary-100))" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
|
||||
{/* Ideal */}
|
||||
<linearGradient id="fillIdeal" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="rgba(var(--color-primary-100))" stopOpacity={0.9} />
|
||||
<stop offset="95%" stopColor="rgba(var(--color-primary-100))" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
|
||||
{/* Ideal - Actual */}
|
||||
<linearGradient id="diff">{diffGradient}</linearGradient>
|
||||
</defs>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{/* Cartesian axis */}
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#C2C8D6"
|
||||
style={{ fontSize: "12px" }}
|
||||
tick={<CustomizedXAxisTicks data={data} endDate={endDate} />}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
tickCount={10}
|
||||
tickLine={true}
|
||||
allowDecimals={false}
|
||||
strokeWidth={1}
|
||||
stroke="#C2C8D6"
|
||||
label={renderYAxisLabel}
|
||||
style={{ fontSize: "10px" }}
|
||||
domain={["dataMin", "dataMax + 2"]}
|
||||
tick={<CustomizedYAxisTicks />}
|
||||
>
|
||||
{/* <Label
|
||||
className="text-sm text-custom-text-400 tracking-widest"
|
||||
angle={270}
|
||||
x={0}
|
||||
value={"ISSUES"}
|
||||
position="insideBottomLeft"
|
||||
// content={(e) => renderYAxisLabel(data, e)}
|
||||
/> */}
|
||||
<Label angle={270} position="insideBottomLeft" content={renderYAxisLabel} />
|
||||
</YAxis>
|
||||
{/* Line charts */}
|
||||
{/* Time left */}
|
||||
<Area dataKey="timeLeft" stroke="#EBF1FF" strokeWidth={0} fill={`url(#fillTimeLeft)`} />
|
||||
<Area dataKey="timeLeft" stroke="#EBF1FF" strokeWidth={0} fill="#E0EAFF" fillOpacity={0.5} />
|
||||
|
||||
{/* Beyond Time */}
|
||||
<Area dataKey="beyondTime" stroke="#FF9999" strokeWidth={0} fill={`url(#fillTimeBeyond)`} />
|
||||
<ReferenceArea
|
||||
x1={endDate}
|
||||
x2={dataWithRange[dataWithRange.length - 1].date}
|
||||
y2={maxScope(data)}
|
||||
stroke="#EBF1FF"
|
||||
fill="#FFE5E5"
|
||||
>
|
||||
<Label
|
||||
fontSize={14}
|
||||
className="font-medium"
|
||||
angle={270}
|
||||
value={"Beyond Time"}
|
||||
fill="#FF9999"
|
||||
position="middle"
|
||||
/>
|
||||
</ReferenceArea>
|
||||
|
||||
{/* Today */}
|
||||
<ReferenceLine x={getToday(true) as string} stroke="black" label="" strokeDasharray="3 3" />
|
||||
{/* Beyond Time */}
|
||||
<ReferenceLine x={endDate} stroke="#FF6666" label="" strokeDasharray="3 3" />
|
||||
{/* Started */}
|
||||
<Line type="linear" dataKey="started" strokeWidth={1} stroke="#FF9500" dot={false} />
|
||||
{areaToHighlight === "started" && (
|
||||
<Area
|
||||
dataKey="started"
|
||||
fill="url(#fillStarted)"
|
||||
fillOpacity={0.4}
|
||||
stroke="#FF9500"
|
||||
strokeWidth={1}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
{/* Actual */}
|
||||
<Line type="linear" dataKey="actual" strokeWidth={3} stroke="#26D950" dot={false} isAnimationActive={false} />
|
||||
{areaToHighlight === "actual" && (
|
||||
<Area
|
||||
dataKey="actual"
|
||||
fill="url(#fillPending)"
|
||||
fillOpacity={0.4}
|
||||
stroke="#26D950"
|
||||
strokeWidth={4}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
{/* Ideal */}
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="ideal"
|
||||
strokeWidth={1}
|
||||
stroke="#B8CEFF"
|
||||
dot={false}
|
||||
strokeDasharray="5 5"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
{areaToHighlight === "ideal" && (
|
||||
<Area
|
||||
dataKey="ideal"
|
||||
fill="url(#fillIdeal)"
|
||||
fillOpacity={0.4}
|
||||
stroke="#B8CEFF"
|
||||
strokeWidth={0}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
{/* Scope */}
|
||||
<Line
|
||||
type="step"
|
||||
dataKey="scope"
|
||||
strokeWidth={2}
|
||||
stroke="rgba(var(--color-primary-100))"
|
||||
dot={false}
|
||||
animationEasing="ease-in"
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{areaToHighlight === "scope" && (
|
||||
<LabelList offset={10} dataKey="scope" content={(e) => renderScopeLabel(data, e)} position={"insideLeft"} />
|
||||
)}
|
||||
</Line>
|
||||
{areaToHighlight === "scope" && (
|
||||
<Area
|
||||
type="step"
|
||||
dataKey="scope"
|
||||
fill="url(#fillScope)"
|
||||
fillOpacity={0.4}
|
||||
stroke="rgba(var(--color-primary-100))"
|
||||
strokeWidth={0}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
{/* Ideal - Actual */}
|
||||
<Area dataKey="range" stroke="#8884d8" strokeWidth={0} fill={`url(#diff)`} isAnimationActive={false} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
export default ActiveCycleChart;
|
||||
130
web/core/components/cycles/active-cycle/cycle-chart/helper.tsx
Normal file
130
web/core/components/cycles/active-cycle/cycle-chart/helper.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { getToday } from "@/helpers/date-time.helper";
|
||||
import { TCycleProgress } from "@plane/types";
|
||||
|
||||
const getIntersectionColor = (_intersection, isLast = false) => {
|
||||
if (isLast) {
|
||||
return _intersection.line1isHigherNext ? "#FFE5E5" : "#D4F7DC";
|
||||
}
|
||||
|
||||
return _intersection.line1isHigher ? "#FFE5E5" : "#D4F7DC";
|
||||
};
|
||||
|
||||
// line intercept math by Paul Bourke http://paulbourke.net/geometry/pointlineplane/
|
||||
// Determine the intersection point of two line segments
|
||||
// Return FALSE if the lines don't intersect
|
||||
const intersect = (x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number) => {
|
||||
// Check if none of the lines are of length 0
|
||||
if ((x1 === x2 && y1 === y2) || (x3 === x4 && y3 === y4)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const denominator = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
|
||||
|
||||
// Lines are parallel
|
||||
if (denominator === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denominator;
|
||||
let ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denominator;
|
||||
|
||||
// is the intersection along the segments
|
||||
if (ua < 0 || ua > 1 || ub < 0 || ub > 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Return a object with the x and y coordinates of the intersection
|
||||
let x = x1 + ua * (x2 - x1);
|
||||
let y = y1 + ua * (y2 - y1);
|
||||
|
||||
const line1isHigher = y1 > y3;
|
||||
const line1isHigherNext = y2 > y4;
|
||||
|
||||
return { x, y, line1isHigher, line1isHigherNext };
|
||||
};
|
||||
export const maxScope = (data: TCycleProgress[]) => Math.max(...data.map((d) => d.scope || 0));
|
||||
|
||||
const generateDateArray = (startDate: Date, endDate: Date) => {
|
||||
// Convert the start and end dates to Date objects if they aren't already
|
||||
let start = new Date(startDate);
|
||||
// start.setDate(start.getDate() + 1);
|
||||
let end = new Date(endDate);
|
||||
end.setDate(end.getDate() + 1);
|
||||
|
||||
// Create an empty array to store the dates
|
||||
let dateArray = [];
|
||||
|
||||
// Use a while loop to generate dates between the range
|
||||
while (start <= end) {
|
||||
// Increment the date by 1 day (86400000 milliseconds)
|
||||
start.setDate(start.getDate() + 1);
|
||||
// Push the current date (converted to ISO string for consistency)
|
||||
dateArray.push({
|
||||
date: new Date(start).toISOString().split("T")[0],
|
||||
});
|
||||
}
|
||||
|
||||
return dateArray;
|
||||
};
|
||||
|
||||
export const chartHelper = (data: TCycleProgress[], endDate: Date) => {
|
||||
// Get today's date
|
||||
const today = getToday();
|
||||
const scopeToday = data[data.length - 1].scope;
|
||||
const idealToday = data[data.length - 1].ideal;
|
||||
const extendedArray = generateDateArray(today as Date, endDate);
|
||||
|
||||
// add `range` to data for Area
|
||||
const dataWithRange = [...data, ...extendedArray].map((d: Partial<TCycleProgress>) => {
|
||||
return {
|
||||
...d,
|
||||
range: d.actual !== undefined && d.ideal !== undefined ? [d.actual, d.ideal] : [],
|
||||
timeLeft: new Date(d.date!) < today ? [] : [0, maxScope(data)],
|
||||
ideal: new Date(d.date!) < today ? d.ideal : endDate >= new Date(d.date!) ? idealToday : null,
|
||||
scope: new Date(d.date!) < today ? d.scope : endDate >= new Date(d.date!) ? scopeToday : null,
|
||||
beyondTime: endDate <= new Date(d.date!) ? [0, maxScope(data)] : [],
|
||||
};
|
||||
});
|
||||
|
||||
// need to find intersections as points where we to change fill color
|
||||
const intersections = data
|
||||
.map((d, i: number) => intersect(i, d.actual, i + 1, data[i + 1]?.actual, i, d.ideal, i + 1, data[i + 1]?.ideal))
|
||||
.filter((d) => d && !isNaN(d.x));
|
||||
|
||||
// filtering out segments without intersections & duplicates (in case end current 2 segments are also
|
||||
// start of 2 next segments)
|
||||
const filteredIntersections = intersections.filter(
|
||||
(d, i: number) => i === intersections.length - 1 || d.x !== intersections[i - 1]?.x
|
||||
);
|
||||
|
||||
const diffGradient = filteredIntersections.length ? (
|
||||
filteredIntersections.map((intersection, i: number) => {
|
||||
const nextIntersection = filteredIntersections[i + 1];
|
||||
|
||||
let closeColor = "";
|
||||
let startColor = "";
|
||||
|
||||
const isLast = i === filteredIntersections.length - 1;
|
||||
|
||||
if (isLast) {
|
||||
closeColor = getIntersectionColor(intersection);
|
||||
startColor = getIntersectionColor(intersection, true);
|
||||
} else {
|
||||
closeColor = getIntersectionColor(intersection);
|
||||
startColor = getIntersectionColor(nextIntersection);
|
||||
}
|
||||
|
||||
const offset = intersection.x / (data.filter((d) => d.actual !== undefined && d.ideal !== undefined).length - 1);
|
||||
return (
|
||||
<>
|
||||
<stop offset={offset} stopColor={closeColor} stopOpacity={0.9} />
|
||||
<stop offset={offset} stopColor={startColor} stopOpacity={0.9} />
|
||||
</>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<stop offset={0} stopColor={data[0].actual > data[0].ideal ? "red" : "blue"} />
|
||||
);
|
||||
|
||||
return { diffGradient, dataWithRange };
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { TCycleProgress } from "@plane/types";
|
||||
|
||||
const renderScopeLabel = (data: TCycleProgress[], props: any) => {
|
||||
const { x, y, value } = props;
|
||||
const prevValue = data[props.index - 1]?.scope;
|
||||
|
||||
return prevValue && prevValue !== value ? (
|
||||
<g>
|
||||
<text
|
||||
x={x - 30}
|
||||
y={26}
|
||||
dy={-4}
|
||||
fill="#003FCC"
|
||||
fontSize={10}
|
||||
className="font-bold absolute top-0"
|
||||
textAnchor="end"
|
||||
>
|
||||
{prevValue < value ? <>▲ </> : <>▼ </>}
|
||||
{prevValue < value ? "+" : "-"}
|
||||
{`${Math.abs(value - prevValue)}`}
|
||||
</text>
|
||||
<line x1={x - 40} y1={26} x2={x - 40} y2={30} stroke="#003FCC" stroke-width="1"></line>
|
||||
</g>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
|
||||
const renderYAxisLabel = (props: any) => {
|
||||
const { x, y, value } = props;
|
||||
return <text fill={"#003FCC"} fontSize={10} className="font-bold" textAnchor="start"></text>;
|
||||
};
|
||||
export { renderScopeLabel, renderYAxisLabel };
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getToday } from "@/helpers/date-time.helper";
|
||||
|
||||
const CustomizedXAxisTicks = (props) => {
|
||||
const { x, y, payload, data, endDate } = props;
|
||||
const [year, month, day] = payload.value.split("-");
|
||||
const monthName = new Date(payload.value).toLocaleString("default", { month: "short" });
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
{(day === "01" || payload.index === 0 || payload.value === endDate) && (
|
||||
<>
|
||||
<line x1="0" y1="-8" x2="0" y2="0" stroke="#C2C8D6" stroke-width="1"></line>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
dy={12}
|
||||
textAnchor={payload.index === data.length - 1 ? "end" : "start"}
|
||||
fill="#666"
|
||||
style={{ fontSize: "10px" }}
|
||||
>
|
||||
{day === "01" && monthName} {day} {day === "01" && <>→</>}
|
||||
</text>
|
||||
{(payload.index === 0 || payload.value === endDate) && (
|
||||
<text
|
||||
x={0}
|
||||
y={12}
|
||||
dy={16}
|
||||
textAnchor={payload.index === data.length - 1 ? "end" : "start"}
|
||||
fill="#666"
|
||||
style={{ fontSize: "10px" }}
|
||||
>
|
||||
{payload.index === 0 ? "Start" : "End"}
|
||||
</text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{payload.value === getToday(true) && (
|
||||
<>
|
||||
<line x1="0" y1="-8" x2="0" y2="0" stroke="#C2C8D6" stroke-width="1"></line>
|
||||
<text x={0} y={0} dy={12} textAnchor={"middle"} fill="#666" style={{ fontSize: "10px" }}>
|
||||
{day}
|
||||
</text>
|
||||
<svg x={-17} y={18} dy={18} width="34" height="16px" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect rx="2" width="100%" height="100%" fill="#667699" />
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="10px">
|
||||
Today
|
||||
</text>
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
const CustomizedYAxisTicks = (props) => {
|
||||
const { x, y, payload } = props;
|
||||
return (
|
||||
<text x={x - 10} y={y} dy={3} textAnchor="middle" fill="#666" style={{ fontSize: "10px" }}>
|
||||
{payload.value}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
export { CustomizedXAxisTicks, CustomizedYAxisTicks };
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Card, DoneState, ECardSpacing, InProgressState, PlannedState } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
active: boolean;
|
||||
payload: any; // TODO: fix type
|
||||
label: string;
|
||||
};
|
||||
const CustomTooltip = ({ active, payload, label }: Props) => {
|
||||
if (active && payload && payload.length) {
|
||||
payload = payload[0]?.payload;
|
||||
const [year, month, day] = label.split("-");
|
||||
const monthName = new Date(label).toLocaleString("default", { month: "short" });
|
||||
return (
|
||||
<Card className="flex flex-col" spacing={ECardSpacing.SM}>
|
||||
<p className="text-xs text-custom-text-400 border-b pb-2">{`${day} ${monthName}'${parseInt(year) % 100}`}</p>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<span className="flex text-xs text-custom-text-300 gap-1">
|
||||
<PlannedState className="my-auto" width="14" height="14" />
|
||||
<span className="font-semibold">{payload.ideal}</span>
|
||||
<span> planned</span>
|
||||
</span>
|
||||
<span className="flex text-xs text-custom-text-300 gap-1 items-center">
|
||||
<InProgressState className="my-auto items-center" width="14" height="14" />
|
||||
<span className="font-semibold">{payload.scope - payload.completed - payload.actual}</span>
|
||||
<span> in-progress</span>
|
||||
</span>
|
||||
<span className="flex text-xs text-custom-text-300 gap-1 items-center ml-0.5">
|
||||
<DoneState className="my-auto" width="12" height="12" />
|
||||
<span className="font-semibold">{payload.completed}</span>
|
||||
<span> done</span>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
export default CustomTooltip;
|
||||
70
web/core/components/cycles/active-cycle/progress-donut.tsx
Normal file
70
web/core/components/cycles/active-cycle/progress-donut.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { use, useEffect, useRef, useState } from "react";
|
||||
import { CircularProgressIndicator } from "@plane/ui";
|
||||
import { TCycleProgress } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
progress: TCycleProgress | null;
|
||||
days_left: number;
|
||||
};
|
||||
|
||||
const ProgressDonut = (props: Props) => {
|
||||
const { progress, days_left } = props;
|
||||
const [hoverStep, setHoverStep] = useState<number>(0);
|
||||
const intervalId = useRef<NodeJS.Timer | null>(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (intervalId.current) return;
|
||||
intervalId.current = setInterval(() => {
|
||||
setHoverStep((prev) => (prev === 3 ? 1 : prev + 1));
|
||||
}, 1000);
|
||||
};
|
||||
const handleMouseLeave = () => {
|
||||
clear();
|
||||
setHoverStep(0);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
if (intervalId.current) {
|
||||
clearInterval(intervalId.current);
|
||||
intervalId.current = null;
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (hoverStep === 3) clear();
|
||||
}, [hoverStep]);
|
||||
|
||||
return (
|
||||
progress && (
|
||||
<div
|
||||
className="group flex items-center justify-between py-1 rounded-full"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<CircularProgressIndicator size={82} percentage={50} strokeWidth={2} strokeColor={"text-green-100"}>
|
||||
<span className="text-lg text-custom-primary-200 font-medium">
|
||||
{hoverStep === 3
|
||||
? days_left
|
||||
: `${(((progress.scope - progress.completed) * 100) / progress.scope).toFixed(0)}%`}
|
||||
</span>
|
||||
|
||||
{hoverStep === 1 && (
|
||||
<div className="text-custom-primary-200 text-[8px] uppercase whitespace-nowrap">
|
||||
<span className="font-semibold">{progress.completed}</span> Issues <br /> done
|
||||
</div>
|
||||
)}
|
||||
{hoverStep === 2 && (
|
||||
<div className="text-custom-primary-200 text-[8px] uppercase whitespace-nowrap">
|
||||
<span className="font-semibold">{progress.pending}</span> Issues <br /> pending
|
||||
</div>
|
||||
)}
|
||||
{hoverStep === 3 && (
|
||||
<div className="text-custom-primary-200 text-[8px] uppercase whitespace-nowrap">
|
||||
{days_left === 1 ? "Day" : "Days"} <br /> left
|
||||
</div>
|
||||
)}
|
||||
</CircularProgressIndicator>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
export default ProgressDonut;
|
||||
67
web/core/components/cycles/active-cycle/progress-header.tsx
Normal file
67
web/core/components/cycles/active-cycle/progress-header.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC, useRef } from "react";
|
||||
// types
|
||||
import { ArrowRight, CalendarDays } from "lucide-react";
|
||||
// icons
|
||||
import { Row } from "@plane/ui";
|
||||
// helpers
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { useMember } from "@/hooks/store";
|
||||
import { CycleListItemAction } from "../list";
|
||||
import ProgressDonut from "./progress-donut";
|
||||
import { dateFormatter, daysLeft } from "@/helpers/date-time.helper";
|
||||
import { TCycleProgress } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
progress: TCycleProgress[] | null;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycleId: string;
|
||||
cycleDetails: any;
|
||||
};
|
||||
|
||||
export const CycleProgressHeader: FC<Props> = (props: Props) => {
|
||||
const { workspaceSlug, projectId, cycleId, progress, cycleDetails } = props;
|
||||
const { getUserDetails } = useMember();
|
||||
|
||||
const parentRef = useRef(null);
|
||||
const createdByDetails = cycleDetails.created_by ? getUserDetails(cycleDetails.created_by) : undefined;
|
||||
const progressToday = progress && progress[progress.length - 1];
|
||||
|
||||
return (
|
||||
<Row className={cn("flex items-center justify-between py-4 bg-custom-sidebar-background-100")}>
|
||||
<div className="flex gap-6 h-full">
|
||||
{progress && <ProgressDonut progress={progressToday} days_left={daysLeft(cycleDetails.end_date)} />}
|
||||
<div className="flex flex-col h-full my-auto">
|
||||
<div className="text-xs text-custom-primary-200 font-medium self-start">Currently active cycle</div>
|
||||
<div className="inline-block line-clamp-1 truncate font-semibold text-custom-text-100 my-1 text-xl">
|
||||
{cycleDetails.name}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* Duration */}
|
||||
<div className="flex gap-1 text-xs text-custom-text-400 font-medium items-center">
|
||||
<CalendarDays className="h-3 w-3 flex-shrink-0 my-auto" />
|
||||
<span>{dateFormatter(cycleDetails.start_date)}</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0 my-auto" />
|
||||
<span>{dateFormatter(cycleDetails.end_date)}</span>
|
||||
</div>
|
||||
{/* created by */}
|
||||
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<CycleListItemAction
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
cycleId={cycleId}
|
||||
cycleDetails={cycleDetails}
|
||||
parentRef={parentRef}
|
||||
isActive={true}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
@@ -4,20 +4,16 @@ import { observer } from "mobx-react";
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
// ui
|
||||
import { Row } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
ActiveCycleProductivity,
|
||||
ActiveCycleProgress,
|
||||
ActiveCycleStats,
|
||||
CycleListGroupHeader,
|
||||
CyclesListItem,
|
||||
} from "@/components/cycles";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { useCycle } from "@/hooks/store";
|
||||
import { ActiveCycleIssueDetails } from "@/store/issue/cycle";
|
||||
import useCyclesDetails from "./use-cycles-details";
|
||||
import { CycleProgressHeader } from "./progress-header";
|
||||
import ActiveCycleChart from "./cycle-chart/chart";
|
||||
import { useState } from "react";
|
||||
import Summary from "./summary";
|
||||
import Selection from "./selection";
|
||||
import useActiveCycle from "./use-active-cycle";
|
||||
|
||||
interface IActiveCycleDetails {
|
||||
workspaceSlug: string;
|
||||
@@ -26,12 +22,18 @@ interface IActiveCycleDetails {
|
||||
|
||||
export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
|
||||
const { workspaceSlug, projectId } = props;
|
||||
const { currentProjectActiveCycle, currentProjectActiveCycleId } = useCycle();
|
||||
const {
|
||||
handleFiltersUpdate,
|
||||
plotType,
|
||||
estimateType,
|
||||
handlePlotChange,
|
||||
handleEstimateChange,
|
||||
cycle: activeCycle,
|
||||
cycleIssueDetails,
|
||||
} = useCyclesDetails({ workspaceSlug, projectId, cycleId: currentProjectActiveCycleId });
|
||||
} = useActiveCycle(workspaceSlug, projectId);
|
||||
const { activeCycleProgress } = useCycle();
|
||||
|
||||
const [areaToHighlight, setAreaToHighlight] = useState<string>("");
|
||||
|
||||
if (!activeCycle) return;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -39,45 +41,42 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 cursor-pointer">
|
||||
<CycleListGroupHeader title="Active cycle" type="current" isExpanded={open} />
|
||||
<CycleProgressHeader
|
||||
cycleDetails={activeCycle}
|
||||
progress={activeCycleProgress}
|
||||
projectId={projectId}
|
||||
cycleId={activeCycle.id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
<Disclosure.Panel>
|
||||
{!currentProjectActiveCycle ? (
|
||||
{!activeCycle ? (
|
||||
<EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />
|
||||
) : (
|
||||
<div className="flex flex-col border-b border-custom-border-200">
|
||||
{currentProjectActiveCycleId && (
|
||||
<CyclesListItem
|
||||
key={currentProjectActiveCycleId}
|
||||
cycleId={currentProjectActiveCycleId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
className="!border-b-transparent"
|
||||
/>
|
||||
{activeCycleProgress && (
|
||||
<Row className="flex bg-custom-background-100 md:h-[420px] justify-between !pr-0 flex-col md:flex-row">
|
||||
<Summary
|
||||
setAreaToHighlight={setAreaToHighlight}
|
||||
data={activeCycleProgress}
|
||||
plotType={plotType}
|
||||
estimateType={estimateType}
|
||||
/>
|
||||
<div className="h-full w-full flex-1">
|
||||
<Selection
|
||||
plotType={plotType}
|
||||
estimateType={estimateType}
|
||||
handlePlotChange={handlePlotChange}
|
||||
handleEstimateChange={handleEstimateChange}
|
||||
/>
|
||||
<ActiveCycleChart
|
||||
areaToHighlight={areaToHighlight}
|
||||
data={activeCycleProgress}
|
||||
cycle={activeCycle}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
<Row className="bg-custom-background-100 pt-3 pb-6">
|
||||
<div className="grid grid-cols-1 bg-custom-background-100 gap-3 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<ActiveCycleProgress
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
cycle={activeCycle}
|
||||
/>
|
||||
<ActiveCycleProductivity
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
cycle={activeCycle}
|
||||
/>
|
||||
<ActiveCycleStats
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
cycle={activeCycle}
|
||||
cycleId={currentProjectActiveCycleId}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
cycleIssueDetails={cycleIssueDetails as ActiveCycleIssueDetails}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure.Panel>
|
||||
|
||||
61
web/core/components/cycles/active-cycle/selection.tsx
Normal file
61
web/core/components/cycles/active-cycle/selection.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useCycle } from "@/hooks/store";
|
||||
import { ICycle, TCycleEstimateType, TCyclePlotType } from "@plane/types";
|
||||
import { CustomSelect, Row } from "@plane/ui";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
type options = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
const cycleChartOptions: options[] = [
|
||||
{ value: "burndown", label: "Burn-down" },
|
||||
{ value: "burnup", label: "Burn-up" },
|
||||
];
|
||||
const cycleEstimateOptions: options[] = [
|
||||
{ value: "issues", label: "issues" },
|
||||
{ value: "points", label: "points" },
|
||||
];
|
||||
|
||||
export type TSelectionProps = {
|
||||
plotType: TCyclePlotType;
|
||||
estimateType: TCycleEstimateType;
|
||||
handlePlotChange: (value: TCyclePlotType) => Promise<void>;
|
||||
handleEstimateChange: (value: TCycleEstimateType) => Promise<void>;
|
||||
};
|
||||
export type TDropdownProps = {
|
||||
value: string;
|
||||
onChange: (value: TCyclePlotType | TCycleEstimateType) => Promise<void>;
|
||||
options: any[];
|
||||
};
|
||||
const Dropdown = ({ value, onChange, options }: TDropdownProps) => {
|
||||
return (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={<span>{options.find((v) => v.value === value)?.label ?? "None"}</span>}
|
||||
onChange={onChange}
|
||||
maxHeight="lg"
|
||||
buttonClassName="bg-custom-background-90 border-none rounded text-sm font-medium"
|
||||
>
|
||||
{options.map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const Selection = observer((props: TSelectionProps) => {
|
||||
const { plotType, estimateType, handlePlotChange, handleEstimateChange } = props;
|
||||
|
||||
return (
|
||||
<Row className="h-[40px] py-4 flex text-sm items-center gap-2 font-medium">
|
||||
<Dropdown value={plotType} onChange={handlePlotChange} options={cycleChartOptions} />
|
||||
<span className="text-custom-text-400">for</span>
|
||||
<Dropdown value={estimateType} onChange={handleEstimateChange} options={cycleEstimateOptions} />
|
||||
</Row>
|
||||
);
|
||||
});
|
||||
|
||||
export default Selection;
|
||||
122
web/core/components/cycles/active-cycle/summary.tsx
Normal file
122
web/core/components/cycles/active-cycle/summary.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { cn } from "@plane/editor";
|
||||
import { TCycleProgress } from "@plane/types";
|
||||
import { groupBy } from "lodash";
|
||||
import { Info, TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
type Props = {
|
||||
setAreaToHighlight: (area: string) => void;
|
||||
data: TCycleProgress[];
|
||||
plotType: string;
|
||||
estimateType: string;
|
||||
};
|
||||
const Summary = observer((props: Props) => {
|
||||
const { setAreaToHighlight, data, plotType, estimateType } = props;
|
||||
const dataToday = data[data.length - 1];
|
||||
const isBehind = dataToday.ideal < dataToday.actual;
|
||||
|
||||
const scopeChangeCount = data.reduce((acc, curr, index, array) => {
|
||||
// Skip the first element as there's nothing to compare it with
|
||||
if (index === 0) return acc;
|
||||
|
||||
// Compare current scope with the previous scope
|
||||
if (curr.scope !== array[index - 1].scope) {
|
||||
return acc + 1;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="md:w-[350px] md:border-r border-custom-border-200 py-4 pr-6">
|
||||
<div className="text-xs text-custom-text-400 font-medium">Summary of cycle issues</div>
|
||||
<div
|
||||
className={cn("border-b border-custom-border-200 w-full flex text-red-500 pb-2", {
|
||||
"text-green-500": !isBehind,
|
||||
})}
|
||||
>
|
||||
{isBehind ? <TrendingDown className="my-auto mr-2" /> : <TrendingUp className="my-auto mr-2" />}
|
||||
<div className="text-md font-medium my-auto flex-1">
|
||||
{isBehind ? "Trailing" : "Leading"} by {Math.abs(dataToday.ideal - dataToday.actual)}{" "}
|
||||
{Math.abs(dataToday.ideal - dataToday.actual) > 1 ? estimateType : estimateType.slice(0, -1)}
|
||||
</div>
|
||||
<div className="text-[20px] self-end">🏃</div>
|
||||
</div>
|
||||
<div className="space-y-4 mt-2 pb-4 border-b border-custom-border-200">
|
||||
<div className="flex text-xs text-custom-text-400 font-medium">
|
||||
<span className="w-5/6 capitalize">{estimateType.slice(0, -1)} states on chart</span>
|
||||
<span className="w-1/6 text-end capitalize">{estimateType}</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex text-sm"
|
||||
onMouseEnter={() => setAreaToHighlight("ideal")}
|
||||
onMouseDown={() => setAreaToHighlight("")}
|
||||
>
|
||||
<hr className="my-auto border-[1px] border-dashed w-[12px] border-indigo-400 mr-2"></hr>
|
||||
<span className="w-5/6">Today’s ideal {plotType === "burndown" ? "Pending" : "Done"}</span>
|
||||
<span className="w-1/6 text-end font-bold text-custom-text-300">{dataToday.ideal}</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex text-sm w-full justify-between"
|
||||
onMouseEnter={() => setAreaToHighlight("actual")}
|
||||
onMouseLeave={() => setAreaToHighlight("")}
|
||||
>
|
||||
<div className="flex">
|
||||
<hr className="my-auto h-[2px] border-0 w-[12px] bg-green-400 mr-2"></hr>
|
||||
<span className="w-5/6 my-auto">{plotType === "burndown" ? "Pending" : "Done"}</span>
|
||||
</div>
|
||||
<span className="text-end font-bold text-custom-text-300 bg-green-400 py-0.5 px-1 rounded text-white">
|
||||
{dataToday.actual}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex text-sm w-full justify-between"
|
||||
onMouseEnter={() => setAreaToHighlight("started")}
|
||||
onMouseLeave={() => setAreaToHighlight("")}
|
||||
>
|
||||
<div className="flex">
|
||||
<hr className="my-auto h-[2px] border-0 w-[12px] bg-orange-500 mr-2"></hr>
|
||||
<span className="w-5/6 my-auto">Started</span>
|
||||
</div>
|
||||
<span className="text-end font-bold text-custom-text-300 bg-orange-500 py-0.5 px-1 rounded text-white">
|
||||
{dataToday.started}
|
||||
</span>{" "}
|
||||
</div>
|
||||
<div
|
||||
className="flex text-sm"
|
||||
onMouseEnter={() => setAreaToHighlight("scope")}
|
||||
onMouseLeave={() => setAreaToHighlight("")}
|
||||
>
|
||||
<hr className="my-auto h-[2px] border-0 w-[12px] bg-blue-500 mr-2"></hr>
|
||||
<span className="w-5/6">Scope</span>
|
||||
<span className="w-1/6 text-end font-bold text-custom-text-300">{dataToday.scope}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 mt-2 pb-4 border-b border-custom-border-200">
|
||||
<div className="flex text-xs text-custom-text-400 font-medium">
|
||||
<span className="w-5/6">Other {estimateType.slice(0, -1)} states</span>
|
||||
</div>
|
||||
<div className="flex text-sm">
|
||||
<span className="w-5/6">Unstarted</span>
|
||||
<span className="w-1/6 text-end font-bold text-custom-text-300">{dataToday.unstarted}</span>
|
||||
</div>
|
||||
<div className="flex text-sm">
|
||||
<span className="w-5/6">Backlog</span>
|
||||
<span className="w-1/6 text-end font-bold text-custom-text-300">{dataToday.backlog}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-custom-text-400 font-medium flex pt-2 gap-2">
|
||||
<Info className="text-xs mt-[2px]" size={12} />
|
||||
<div className="flex flex-col space-y-2">
|
||||
<span>
|
||||
{dataToday.cancelled} Cancelled {estimateType} (excluded)
|
||||
</span>
|
||||
<span>
|
||||
Scope has changed {scopeChangeCount} {scopeChangeCount === 1 ? "time" : "times"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
export default Summary;
|
||||
7
web/core/components/cycles/active-cycle/types.ts
Normal file
7
web/core/components/cycles/active-cycle/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
type TProgress = {
|
||||
percentage: number;
|
||||
pendingIssues?: number;
|
||||
completedIssues?: number;
|
||||
daysLeft: number;
|
||||
};
|
||||
export { TProgress };
|
||||
36
web/core/components/cycles/active-cycle/use-active-cycle.tsx
Normal file
36
web/core/components/cycles/active-cycle/use-active-cycle.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useCycle } from "@/hooks/store";
|
||||
import { TCycleEstimateType, TCyclePlotType } from "@plane/types";
|
||||
import useCyclesDetails from "./use-cycles-details";
|
||||
|
||||
const useActiveCycle = (workspaceSlug: string, projectId: string) => {
|
||||
const { getPlotTypeByCycleId, getEstimateTypeByCycleId, setPlotType, setEstimateType, currentProjectActiveCycleId } =
|
||||
useCycle();
|
||||
const { cycle } = useCyclesDetails({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId: currentProjectActiveCycleId,
|
||||
});
|
||||
|
||||
// derived values
|
||||
const plotType: TCyclePlotType = (cycle && getPlotTypeByCycleId(cycle.id)) || "burndown";
|
||||
const estimateType: TCycleEstimateType = (cycle && getEstimateTypeByCycleId(cycle.id)) || "issues";
|
||||
|
||||
const handlePlotChange = async (value: TCyclePlotType) => {
|
||||
if (!workspaceSlug || !projectId || !cycle || !cycle.id) return;
|
||||
setPlotType(cycle.id, value);
|
||||
};
|
||||
|
||||
const handleEstimateChange = async (value: TCycleEstimateType) => {
|
||||
if (!workspaceSlug || !projectId || !cycle || !cycle.id) return;
|
||||
setEstimateType(cycle.id, value);
|
||||
};
|
||||
|
||||
return {
|
||||
plotType,
|
||||
estimateType,
|
||||
handlePlotChange,
|
||||
handleEstimateChange,
|
||||
cycle,
|
||||
};
|
||||
};
|
||||
export default useActiveCycle;
|
||||
@@ -31,6 +31,7 @@ type Props = {
|
||||
cycleId: string;
|
||||
cycleDetails: ICycle;
|
||||
parentRef: React.RefObject<HTMLDivElement>;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<ICycle> = {
|
||||
@@ -39,7 +40,7 @@ const defaultValues: Partial<ICycle> = {
|
||||
};
|
||||
|
||||
export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef } = props;
|
||||
const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef, isActive = false } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
// store hooks
|
||||
@@ -186,70 +187,61 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="end_date"
|
||||
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
|
||||
<DateRangeDropdown
|
||||
buttonContainerClassName={`h-6 w-full flex ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"} items-center gap-1.5 text-custom-text-300 border-[0.5px] border-custom-border-300 rounded text-xs`}
|
||||
buttonVariant="transparent-with-text"
|
||||
minDate={new Date()}
|
||||
value={{
|
||||
from: getDate(startDateValue),
|
||||
to: getDate(endDateValue),
|
||||
}}
|
||||
onSelect={(val) => {
|
||||
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
|
||||
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
|
||||
handleDateChange(val?.from, val?.to);
|
||||
}}
|
||||
placeholder={{
|
||||
from: "Start date",
|
||||
to: "End date",
|
||||
}}
|
||||
required={cycleDetails.status !== "draft"}
|
||||
disabled={isDisabled}
|
||||
hideIcon={{ from: renderIcon ?? true, to: renderIcon }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{currentCycle && (
|
||||
<div
|
||||
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: currentCycle.color,
|
||||
backgroundColor: `${currentCycle.color}20`,
|
||||
}}
|
||||
>
|
||||
{currentCycle.value === "current"
|
||||
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
|
||||
: `${currentCycle.label}`}
|
||||
</div>
|
||||
{!isActive && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="end_date"
|
||||
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
|
||||
<DateRangeDropdown
|
||||
buttonContainerClassName={`h-6 w-full flex ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"} items-center gap-1.5 text-custom-text-300 border-[0.5px] border-custom-border-300 rounded text-xs`}
|
||||
buttonVariant="transparent-with-text"
|
||||
minDate={new Date()}
|
||||
value={{
|
||||
from: getDate(startDateValue),
|
||||
to: getDate(endDateValue),
|
||||
}}
|
||||
onSelect={(val) => {
|
||||
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
|
||||
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
|
||||
handleDateChange(val?.from, val?.to);
|
||||
}}
|
||||
placeholder={{
|
||||
from: "Start date",
|
||||
to: "End date",
|
||||
}}
|
||||
required={cycleDetails.status !== "draft"}
|
||||
disabled={isDisabled}
|
||||
hideIcon={{ from: renderIcon ?? true, to: renderIcon }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* created by */}
|
||||
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
|
||||
{createdByDetails && !isActive && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
|
||||
|
||||
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
|
||||
<div className="flex w-10 cursor-default items-center justify-center">
|
||||
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{cycleDetails.assignee_ids?.map((assignee_id) => {
|
||||
const member = getUserDetails(assignee_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
<Users className="h-4 w-4 text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
{!isActive && (
|
||||
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
|
||||
<div className="flex w-10 cursor-default items-center justify-center">
|
||||
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{cycleDetails.assignee_ids?.map((assignee_id) => {
|
||||
const member = getUserDetails(assignee_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
<Users className="h-4 w-4 text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isEditingAllowed && !cycleDetails.archived_at && (
|
||||
<FavoriteStar
|
||||
|
||||
@@ -36,7 +36,7 @@ export class CycleService extends APIService {
|
||||
projectId: string,
|
||||
cycleId: string
|
||||
): Promise<TProgressSnapshot> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/progress/`)
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-progress/`)
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
TProgressSnapshot,
|
||||
TCycleEstimateDistribution,
|
||||
TCycleDistribution,
|
||||
TCycleEstimateType,
|
||||
TCycleProgress,
|
||||
} from "@plane/types";
|
||||
// helpers
|
||||
import { orderCycles, shouldFilterCycle } from "@/helpers/cycle.helper";
|
||||
@@ -31,7 +33,9 @@ export interface ICycleStore {
|
||||
fetchedMap: Record<string, boolean>;
|
||||
cycleMap: Record<string, ICycle>;
|
||||
plotType: Record<string, TCyclePlotType>;
|
||||
estimatedType: Record<string, TCycleEstimateType>;
|
||||
activeCycleIdMap: Record<string, boolean>;
|
||||
|
||||
// computed
|
||||
currentProjectCycleIds: string[] | null;
|
||||
currentProjectCompletedCycleIds: string[] | null;
|
||||
@@ -41,6 +45,7 @@ export interface ICycleStore {
|
||||
currentProjectActiveCycleId: string | null;
|
||||
currentProjectArchivedCycleIds: string[] | null;
|
||||
currentProjectActiveCycle: ICycle | null;
|
||||
activeCycleProgress: TCycleProgress[] | null;
|
||||
|
||||
// computed actions
|
||||
getFilteredCycleIds: (projectId: string, sortByManual: boolean) => string[] | null;
|
||||
@@ -51,10 +56,12 @@ export interface ICycleStore {
|
||||
getActiveCycleById: (cycleId: string) => ICycle | null;
|
||||
getProjectCycleIds: (projectId: string) => string[] | null;
|
||||
getPlotTypeByCycleId: (cycleId: string) => TCyclePlotType;
|
||||
getEstimateTypeByCycleId: (cycleId: string) => TCycleEstimateType;
|
||||
// actions
|
||||
updateCycleDistribution: (distributionUpdates: DistributionUpdates, cycleId: string) => void;
|
||||
validateDate: (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => Promise<any>;
|
||||
setPlotType: (cycleId: string, plotType: TCyclePlotType) => void;
|
||||
setEstimateType: (cycleId: string, estimateType: TCycleEstimateType) => void;
|
||||
// fetch
|
||||
fetchWorkspaceCycles: (workspaceSlug: string) => Promise<ICycle[]>;
|
||||
fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
|
||||
@@ -91,6 +98,7 @@ export class CycleStore implements ICycleStore {
|
||||
loader: boolean = false;
|
||||
cycleMap: Record<string, ICycle> = {};
|
||||
plotType: Record<string, TCyclePlotType> = {};
|
||||
estimatedType: Record<string, TCycleEstimateType> = {};
|
||||
activeCycleIdMap: Record<string, boolean> = {};
|
||||
//loaders
|
||||
fetchedMap: Record<string, boolean> = {};
|
||||
@@ -108,6 +116,7 @@ export class CycleStore implements ICycleStore {
|
||||
loader: observable.ref,
|
||||
cycleMap: observable,
|
||||
plotType: observable,
|
||||
estimatedType: observable,
|
||||
activeCycleIdMap: observable,
|
||||
fetchedMap: observable,
|
||||
// computed
|
||||
@@ -119,9 +128,11 @@ export class CycleStore implements ICycleStore {
|
||||
currentProjectActiveCycleId: computed,
|
||||
currentProjectArchivedCycleIds: computed,
|
||||
currentProjectActiveCycle: computed,
|
||||
activeCycleProgress: computed,
|
||||
|
||||
// actions
|
||||
setPlotType: action,
|
||||
setEstimateType: action,
|
||||
fetchWorkspaceCycles: action,
|
||||
fetchAllCycles: action,
|
||||
fetchActiveCycle: action,
|
||||
@@ -238,6 +249,40 @@ export class CycleStore implements ICycleStore {
|
||||
return activeCycle || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns active cycle progress for a project
|
||||
*/
|
||||
get activeCycleProgress() {
|
||||
const activeCycle = this.currentProjectActiveCycle;
|
||||
if (!activeCycle?.progress) return null;
|
||||
|
||||
const isTypeIssue = this.getEstimateTypeByCycleId(activeCycle.id) === "issues";
|
||||
const isBurnDown = this.getPlotTypeByCycleId(activeCycle.id) === "burndown";
|
||||
let progress = activeCycle?.progress.map((p) => {
|
||||
const pending = isTypeIssue
|
||||
? p.total_issues - p.completed_issues - p.cancelled_issues
|
||||
: p.total_estimate_points - p.completed_estimate_points - p.cancelled_estimate_points;
|
||||
const completed = isTypeIssue ? p.completed_issues : p.completed_estimate_points;
|
||||
return {
|
||||
date: p.date,
|
||||
scope: isTypeIssue ? p.total_issues : p.total_estimate_points,
|
||||
completed,
|
||||
backlog: isTypeIssue ? p.backlog_issues : p.backlog_estimate_points,
|
||||
started: isTypeIssue ? p.started_issues : p.started_estimate_points,
|
||||
unstarted: isTypeIssue ? p.unstarted_issues : p.unstarted_estimate_points,
|
||||
cancelled: isTypeIssue ? p.cancelled_issues : p.cancelled_estimate_points,
|
||||
pending: pending,
|
||||
// TODO: This is a temporary logic to show the ideal line in the cycle chart
|
||||
ideal: isTypeIssue
|
||||
? p.total_issues - p.completed_issues + (Math.random() < 0.5 ? -1 : 1)
|
||||
: p.total_estimate_points - p.completed_estimate_points + (Math.random() < 0.5 ? -1 : 1),
|
||||
actual: isBurnDown ? pending : completed,
|
||||
};
|
||||
});
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns all archived cycle ids for a project
|
||||
*/
|
||||
@@ -377,9 +422,19 @@ export class CycleStore implements ICycleStore {
|
||||
getPlotTypeByCycleId = (cycleId: string) => {
|
||||
const { projectId } = this.rootStore.router;
|
||||
|
||||
return this.plotType[cycleId] || "burndown";
|
||||
};
|
||||
|
||||
/**
|
||||
* @description gets the estimate type for the module store
|
||||
* @param {TCycleEstimateType} estimateType
|
||||
*/
|
||||
getEstimateTypeByCycleId = (cycleId: string) => {
|
||||
const { projectId } = this.rootStore.router;
|
||||
|
||||
return projectId && this.rootStore.projectEstimate.areEstimateEnabledByProjectId(projectId)
|
||||
? this.plotType[cycleId] || "burndown"
|
||||
: "burndown";
|
||||
? this.estimatedType[cycleId] || "issues"
|
||||
: "issues";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -390,6 +445,14 @@ export class CycleStore implements ICycleStore {
|
||||
set(this.plotType, [cycleId], plotType);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description updates the estimate type for the module store
|
||||
* @param {TCycleEstimateType} estimateType
|
||||
*/
|
||||
setEstimateType = (cycleId: string, estimateType: TCycleEstimateType) => {
|
||||
set(this.estimatedType, [cycleId], estimateType);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch all cycles
|
||||
* @param workspaceSlug
|
||||
@@ -484,7 +547,7 @@ export class CycleStore implements ICycleStore {
|
||||
fetchActiveCycleProgress = async (workspaceSlug: string, projectId: string, cycleId: string) =>
|
||||
await this.cycleService.workspaceActiveCyclesProgress(workspaceSlug, projectId, cycleId).then((progress) => {
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [cycleId], { ...this.cycleMap[cycleId], ...progress });
|
||||
set(this.cycleMap, [cycleId], { ...this.cycleMap[cycleId], progress });
|
||||
});
|
||||
return progress;
|
||||
});
|
||||
|
||||
@@ -357,3 +357,48 @@ export const getReadTimeFromWordsCount = (wordsCount: number): number => {
|
||||
const minutes = wordsCount / wordsPerMinute;
|
||||
return minutes * 60;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description calculates today's date
|
||||
* @param {boolean} format
|
||||
* @returns {Date | string} today's date
|
||||
* @example getToday() // Output: 2024-09-29T00:00:00.000Z
|
||||
* @example getToday(true) // Output: 2024-09-29
|
||||
*/
|
||||
export const getToday = (format: boolean = false) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (!format) return today;
|
||||
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0"); // Months are 0-based, so add 1
|
||||
const day = String(today.getDate()).padStart(2, "0"); // Add leading zero for single digits
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description calculates the date of the day before today
|
||||
* @param {boolean} format
|
||||
* @returns {Date | string} date of the day before today
|
||||
* @example dateFormatter() // Output: "Sept 20, 2024"
|
||||
*/
|
||||
export const dateFormatter = (dateString: string) => {
|
||||
// Convert to Date object
|
||||
let date = new Date(dateString);
|
||||
|
||||
// Options for the desired format (Month Day, Year)
|
||||
let options = { year: "numeric", month: "short", day: "numeric" };
|
||||
|
||||
// Format the date
|
||||
let formattedDate = date.toLocaleDateString("en-US", options);
|
||||
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description calculates days left from today to the end date
|
||||
* @returns {Date | string} number of days left
|
||||
*/
|
||||
export const daysLeft = (end_date: string) => {
|
||||
return end_date ? Math.ceil((new Date(end_date).getTime() - new Date().getTime()) / (1000 * 3600 * 24)) : 0;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user