Compare commits

...

13 Commits

Author SHA1 Message Date
NarayanBavisetti
db06a0d5c6 chore: cycle updates and reaction 2024-09-19 14:54:20 +05:30
gakshita
f5a82e5286 fix: reference line for today 2024-09-19 12:55:40 +05:30
gakshita
f5a9dfba7e Merge branch 'chore-cycles-upgrade' of https://github.com/makeplane/plane into chore-cycles-upgrade 2024-09-19 12:49:27 +05:30
gakshita
d925b4c529 fix: integrational changes 2024-09-19 12:48:44 +05:30
NarayanBavisetti
b20f92607e Merge branch 'chore-cycles-upgrade' of github.com:makeplane/plane into chore-cycles-upgrade 2024-09-18 18:54:10 +05:30
NarayanBavisetti
cfee078398 chore: changed the calculation of cycle analytics 2024-09-18 18:53:00 +05:30
gakshita
743c0f8f0a Merge branch 'preview' of https://github.com/makeplane/plane into chore-cycles-upgrade 2024-09-17 19:06:13 +05:30
NarayanBavisetti
820aa7f6d4 chore: migration changes 2024-09-17 16:53:16 +05:30
gakshita
94e15d693c Merge branch 'feat/cycle-analytics' of https://github.com/makeplane/plane into chore-cycles-upgrade 2024-09-17 16:21:51 +05:30
gakshita
bee30e2fc8 wip: dropdowns 2024-09-17 15:41:23 +05:30
NarayanBavisetti
0864354d69 feat: created new cycle analytics 2024-09-17 14:41:07 +05:30
gakshita
a1bc348a4e fix: progress donut 2024-09-16 16:40:43 +05:30
gakshita
9c36be8b89 chore: graph based on dummy data 2024-09-13 17:09:52 +05:30
44 changed files with 3700 additions and 144 deletions

View File

@@ -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(
{

View File

@@ -43,7 +43,10 @@ from .cycle import (
CycleSerializer,
CycleIssueSerializer,
CycleWriteSerializer,
CycleUpdatesSerializer,
CycleUpdateReactionSerializer,
CycleUserPropertiesSerializer,
CycleAnalyticsSerializer,
)
from .asset import FileAssetSerializer
from .issue import (

View File

@@ -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",
]

View File

@@ -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
]

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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(

View 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)

View File

@@ -40,8 +40,6 @@ from plane.db.models import (
IssueLink,
IssueRelation,
Project,
ProjectMember,
User,
Widget,
WorkspaceMember,
)

View File

@@ -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")

View File

@@ -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)),

View File

@@ -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,
],

View 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)

View File

@@ -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.

View File

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

View File

@@ -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}>"

View File

@@ -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"

View File

@@ -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"""

View File

@@ -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",
)

View File

@@ -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(

View File

@@ -42,5 +42,8 @@
"@types/react": "18.2.48"
},
"packageManager": "yarn@1.22.22",
"name": "plane"
"name": "plane",
"dependencies": {
"recharts": "^2.12.7"
}
}

View File

@@ -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";

View 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>
);

View 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>
);

View File

@@ -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";

View 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>
);

View 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;

View 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 };
};

View File

@@ -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 ? <>&#9650; </> : <>&#9660; </>}
{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 };

View File

@@ -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" && <>&#8594;</>}
</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 };

View File

@@ -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;

View 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;

View 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>
);
};

View File

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

View 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;

View 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">Todays 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;

View File

@@ -0,0 +1,7 @@
type TProgress = {
percentage: number;
pendingIssues?: number;
completedIssues?: number;
daysLeft: number;
};
export { TProgress };

View 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;

View File

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

View File

@@ -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;

View File

@@ -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;
});

View File

@@ -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;
};