[WEB-6464] feat: parallel active cycles (#6159)

This commit is contained in:
Bavisetti Narayan
2026-03-16 16:45:39 +05:30
committed by GitHub
parent 24ddb1cf44
commit 68813f98d4
53 changed files with 1099 additions and 597 deletions
+14 -1
View File
@@ -74,7 +74,9 @@ from plane.utils.timezone_converter import (
convert_to_utc,
user_timezone_converter,
)
from plane.ee.models import EntityProgress
from plane.ee.models import EntityProgress, ProjectFeature
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
from plane.payment.flags.flag import FeatureFlag
class CycleViewSet(BaseViewSet):
@@ -677,6 +679,17 @@ class CycleDateCheckEndpoint(BaseAPIView):
project_id=project_id,
)
# If the project has parallel cycles enabled, overlapping dates are allowed
is_project_parallel = bool(
ProjectFeature.objects.filter(project_id=project_id, workspace__slug=slug)
.values_list("is_parallel_cycles_enabled", flat=True)
.first()
)
allow_parallel = is_project_parallel and check_workspace_feature_flag(FeatureFlag.PARALLEL_CYCLES, slug)
if allow_parallel:
return Response({"status": True}, status=status.HTTP_200_OK)
# Check if any cycle intersects in the given interval
cycles = Cycle.objects.filter(
Q(workspace__slug=slug)
+38 -35
View File
@@ -200,33 +200,34 @@ class EstimatePointEndpoint(BaseViewSet):
estimate__isnull=False,
estimate__type="points",
).exists():
cycle = Cycle.objects.filter(
# Trigger activity for all active cycles — there can be multiple when
# parallel cycles are enabled for the project
active_cycles = Cycle.objects.filter(
start_date__lte=timezone.now(),
end_date__gte=timezone.now(),
project_id=project_id,
workspace__slug=slug,
).first()
)
# If cycle exists, proceed with the logic
if cycle:
cycle_id = str(cycle.id)
for cycle in active_cycles:
issues = Issue.objects.annotate(cycle_id=F("issue_cycle__cycle_id")).filter(
estimate_point_id=estimate_point_id, cycle_id=cycle.id
)
# Trigger the entity issue state activity task
entity_issue_state_activity_task.delay(
issue_cycle_data=[
{
"issue_id": str(issue.id),
"cycle_id": str(cycle_id),
}
for issue in issues
],
user_id=str(request.user.id),
slug=slug,
action="UPDATED",
)
if issues.exists():
# Trigger the entity issue state activity task
entity_issue_state_activity_task.delay(
issue_cycle_data=[
{
"issue_id": str(issue.id),
"cycle_id": str(cycle.id),
}
for issue in issues
],
user_id=str(request.user.id),
slug=slug,
action="UPDATED",
)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -291,33 +292,35 @@ class EstimatePointEndpoint(BaseViewSet):
if Project.objects.filter(
workspace__slug=slug, pk=project_id, estimate__isnull=False, estimate__type="points"
).exists():
cycle = Cycle.objects.filter(
# Trigger activity for all active cycles — there can be multiple when
# parallel cycles are enabled for the project
active_cycles = Cycle.objects.filter(
start_date__lte=timezone.now(),
end_date__gte=timezone.now(),
project_id=project_id,
workspace__slug=slug,
).first()
if cycle:
cycle_id = str(cycle.id)
)
for cycle in active_cycles:
issues = Issue.objects.filter(
estimate_point_id=(new_estimate_id if new_estimate_id else estimate_point_id),
issue_cycle__cycle_id=cycle.id,
)
# Trigger the entity issue state activity task
entity_issue_state_activity_task.delay(
issue_cycle_data=[
{
"issue_id": str(issue.id),
"cycle_id": str(cycle_id),
}
for issue in issues
],
user_id=str(request.user.id),
slug=slug,
action="UPDATED",
)
if issues.exists():
# Trigger the entity issue state activity task
entity_issue_state_activity_task.delay(
issue_cycle_data=[
{
"issue_id": str(issue.id),
"cycle_id": str(cycle.id),
}
for issue in issues
],
user_id=str(request.user.id),
slug=slug,
action="UPDATED",
)
old_estimate_point.delete()
@@ -30,7 +30,8 @@ from plane.db.models import BotTypeEnum, ProjectMember
from plane.utils.exception_logger import log_exception
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
from plane.utils.timezone_converter import convert_to_utc
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
from plane.payment.flags.flag import FeatureFlag
@shared_task
def schedule_cycle(automated_cycle_id: str, project_id: str, bot_id: str):
@@ -51,6 +52,16 @@ def schedule_cycle(automated_cycle_id: str, project_id: str, bot_id: str):
start_date = automated_cycle.start_date
workspace_id = automated_cycle.workspace_id
# Check if the project allows parallel (overlapping) cycles
is_project_parallel = bool(
ProjectFeature.objects.filter(project_id=project_id)
.values_list("is_parallel_cycles_enabled", flat=True)
.first()
)
allow_parallel = is_project_parallel and check_workspace_feature_flag(
FeatureFlag.PARALLEL_CYCLES, automated_cycle.workspace.slug
)
desired_windows = []
for i in range(number_of_cycles):
start_dt = start_date + timedelta(days=i * (cycle_duration + cooldown_period))
@@ -68,23 +79,24 @@ def schedule_cycle(automated_cycle_id: str, project_id: str, bot_id: str):
cycles_to_create = []
for start_dt, end_dt in desired_windows:
# Check if there's any overlap
is_clashing = any((ec["start_date"] <= end_dt and ec["end_date"] >= start_dt) for ec in existing_cycles)
# When parallel cycles are allowed, skip the overlap check
if not allow_parallel:
is_clashing = any((ec["start_date"] <= end_dt and ec["end_date"] >= start_dt) for ec in existing_cycles)
if is_clashing:
logs.append(
AutomatedCycleLog(
automated_cycle=automated_cycle,
workspace_id=workspace_id,
project_id=project_id,
cycle_id=None,
action="cycle_creation_failed",
status="failed",
message=f"Cycle window {start_dt.date()} - {end_dt.date()} overlaps with existing cycle.",
scheduled_at=timezone.now(),
if is_clashing:
logs.append(
AutomatedCycleLog(
automated_cycle=automated_cycle,
workspace_id=workspace_id,
project_id=project_id,
cycle_id=None,
action="cycle_creation_failed",
status="failed",
message=f"Cycle window {start_dt.date()} - {end_dt.date()} overlaps with existing cycle.",
scheduled_at=timezone.now(),
)
)
)
continue
continue
start_dt = convert_to_utc(
date=str(start_dt.date()),
@@ -213,27 +225,65 @@ def maintain_future_cycles():
if scheduled_cycle.is_auto_rollover_enabled:
workspace_slug = Workspace.objects.get(id=scheduled_cycle.workspace_id).slug
# first check if the current active cycle is not the same as the old cycle
current_cycle = (
Cycle.objects.filter(
Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc),
workspace__slug=workspace_slug,
project_id=project_id,
deleted_at__isnull=True,
)
.order_by("start_date")
# Check if the project allows parallel cycles
is_project_parallel = (
ProjectFeature.objects.filter(project_id=project_id)
.values_list("is_parallel_cycles_enabled", flat=True)
.first()
)
if current_cycle and current_cycle.id != cycle_id:
allow_parallel = is_project_parallel and check_workspace_feature_flag(
FeatureFlag.PARALLEL_CYCLES, workspace_slug
)
# Find all currently active cycles excluding the one that just ended
active_cycles = Cycle.objects.filter(
Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc),
workspace__slug=workspace_slug,
project_id=project_id,
deleted_at__isnull=True,
).exclude(id=cycle_id)
active_count = active_cycles.count()
if allow_parallel and active_count > 1:
# get the next upcoming cycle created by the automation bot
next_cycle = (
Cycle.objects.filter(
workspace__slug=workspace_slug,
project_id=project_id,
start_date__gt=current_time_in_utc,
deleted_at__isnull=True,
owned_by_id=bot_id,
)
.order_by("start_date")
.first()
)
if next_cycle:
transfer_cycle_issues(
slug=workspace_slug,
project_id=project_id,
cycle_id=cycle_id,
new_cycle_id=next_cycle.id,
request=None,
user_id=str(bot_id),
)
else:
# if no next cycle is found then skip the rollover
pass
# Multiple active cycles exist — rollover target is ambiguous, skip
pass
elif active_count == 1:
# Exactly one other active cycle — transfer issues into it
transfer_cycle_issues(
slug=workspace_slug,
project_id=project_id,
cycle_id=cycle_id,
new_cycle_id=current_cycle.id,
new_cycle_id=str(active_cycles.first().id),
request=None,
user_id=str(bot_id),
)
else:
# No other active cycle — fall back to the next upcoming cycle
next_cycle = (
Cycle.objects.filter(
workspace__slug=workspace_slug,
@@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-03-11 13:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ee', '0073_backfill_workflow_data'),
]
operations = [
migrations.AddField(
model_name='projectfeature',
name='is_parallel_cycles_enabled',
field=models.BooleanField(default=False),
),
]
+1
View File
@@ -103,6 +103,7 @@ class ProjectFeature(ProjectBaseModel):
is_workflow_enabled = models.BooleanField(default=False)
is_milestone_enabled = models.BooleanField(default=False)
is_automated_cycle_enabled = models.BooleanField(default=False)
is_parallel_cycles_enabled = models.BooleanField(default=False)
class Meta:
verbose_name = "Project Feature"
@@ -59,6 +59,7 @@ class ProjectFeatureSerializer(BaseSerializer):
"is_workflow_enabled",
"is_milestone_enabled",
"is_automated_cycle_enabled",
"is_parallel_cycles_enabled",
"project_id",
]
read_only_fields = [
+48 -20
View File
@@ -21,8 +21,9 @@ from plane.utils.timezone_converter import convert_to_utc_with_timestamp
from plane.ee.views.base import BaseAPIView
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import Cycle
from plane.ee.models import ProjectFeature
from plane.payment.flags.flag import FeatureFlag
from plane.payment.flags.flag_decorator import check_feature_flag
from plane.payment.flags.flag_decorator import check_feature_flag, check_workspace_feature_flag
class CycleStartStopEndpoint(BaseAPIView):
@@ -70,28 +71,55 @@ class CycleStartStopEndpoint(BaseAPIView):
cycle.end_date = current_datetime
if action == "START":
"""
# fetch all the upcoming cycles cycles and sort them by start date and
# check if the current cycle is equal to the first cycle in the list
"""
upcoming_cycles = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
project__archived_at__isnull=True,
)
.filter(start_date__gt=current_datetime)
.order_by("start_date")
).accessible_to(request.user.id, slug)
# Check if the project has parallel cycles enabled
is_project_parallel = bool(
ProjectFeature.objects.filter(project_id=project_id, workspace__slug=slug)
.values_list("is_parallel_cycles_enabled", flat=True)
.first()
)
is_flag_enabled = check_workspace_feature_flag(FeatureFlag.PARALLEL_CYCLES, slug)
allow_parallel = is_project_parallel and is_flag_enabled
upcoming_first_cycle = upcoming_cycles.first()
if upcoming_first_cycle is not None and cycle_id != upcoming_first_cycle.id:
return Response(
{"error": "Cycle is not the next upcoming cycle."},
status=status.HTTP_400_BAD_REQUEST,
if allow_parallel:
# In parallel mode any upcoming cycle can be started — validate only
# that this cycle hasn't already started (start_date is in the future)
upcoming_cycles = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
project__archived_at__isnull=True,
start_date__gt=current_datetime,
).accessible_to(request.user.id, slug)
)
if not upcoming_cycles.filter(id=cycle_id).exists():
return Response(
{"error": "Cycle is not upcoming."},
status=status.HTTP_400_BAD_REQUEST,
)
else:
"""
# fetch all the upcoming cycles and sort them by start date and
# check if the current cycle is equal to the first cycle in the list
"""
upcoming_cycles = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
project__archived_at__isnull=True,
)
.filter(start_date__gt=current_datetime)
.order_by("start_date")
).accessible_to(request.user.id, slug)
upcoming_first_cycle = upcoming_cycles.first()
if upcoming_first_cycle is not None and str(cycle_id) != str(upcoming_first_cycle.id):
return Response(
{"error": "Cycle is not the next upcoming cycle."},
status=status.HTTP_400_BAD_REQUEST,
)
cycle.start_date = current_datetime
cycle.save()
+2
View File
@@ -104,6 +104,8 @@ class FeatureFlag(Enum):
MILESTONES = "MILESTONES"
# Automated Cycles
AUTO_SCHEDULE_CYCLES = "AUTO_SCHEDULE_CYCLES"
# Parallel Cycles
PARALLEL_CYCLES = "PARALLEL_CYCLES"
# Exports
ADVANCED_EXPORTS = "ADVANCED_EXPORTS"
# WORKSPACE_MEMBER_ACTIVITY
@@ -25,7 +25,7 @@ import { ProjectSettingsFeatureControlItem } from "@/components/settings/project
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// plane web imports
import { AutoScheduleCycles } from "@/components/cycles/settings";
import { AutoScheduleCycles, ParallelCycles } from "@/components/cycles/settings";
// plane web imports
import type { Route } from "./+types/page";
import { FeaturesCyclesProjectSettingsHeader } from "./header";
@@ -55,7 +55,7 @@ function FeaturesCyclesSettingsPage({ params }: Route.ComponentProps) {
title={t("project_settings.features.cycles.title")}
description={t("project_settings.features.cycles.description")}
/>
<div className="mt-7">
<div className="mt-7 flex flex-col gap-4">
<ProjectSettingsFeatureControlItem
title={t("project_settings.features.cycles.toggle_title")}
description={t("project_settings.features.cycles.toggle_description")}
@@ -64,9 +64,15 @@ function FeaturesCyclesSettingsPage({ params }: Route.ComponentProps) {
value={!!currentProjectDetails?.cycle_view}
workspaceSlug={workspaceSlug}
/>
{/* Parallel cycles configuration */}
<ParallelCycles
disabled={!currentProjectDetails?.cycle_view}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
</div>
{/* Auto-schedule cycles configuration */}
<div className="mt-12">
{/* Auto-schedule cycles configuration */}
<AutoScheduleCycles disabled={!currentProjectDetails?.cycle_view} />
</div>
</section>
@@ -12,7 +12,7 @@
*/
import { useState } from "react";
import { ArchiveRestoreIcon, StopCircle, Download, LockOpen } from "lucide-react";
import { ArchiveRestoreIcon, Star, StopCircle, Download, LockOpen } from "lucide-react";
// plane imports
import { E_FEATURE_FLAGS, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
@@ -84,6 +84,17 @@ export const useQuickActionsFactory = () => {
action: handler,
}),
createFavoriteMenuItem: (
handler: () => void,
opts: { isFavorite: boolean; shouldRender?: boolean }
): TContextMenuItem => ({
key: "toggle-favorite",
title: opts.isFavorite ? t("remove_from_favorites") : t("add_to_favorites"),
icon: Star,
action: handler,
shouldRender: opts.shouldRender,
}),
createArchiveMenuItem: (
handler: () => void,
opts: { shouldRender?: boolean; disabled?: boolean; description?: string }
@@ -21,6 +21,7 @@ import { useQuickActionsFactory } from "@/components/common/quick-actions/factor
interface UseCycleMenuItemsProps {
cycleDetails: ICycle | undefined;
isEditingAllowed: boolean;
isFavorite: boolean;
workspaceSlug: string;
projectId: string;
cycleId: string;
@@ -30,6 +31,7 @@ interface UseCycleMenuItemsProps {
handleDelete: () => void;
handleCopyLink: () => void;
handleOpenInNewTab: () => void;
handleFavorite: () => void;
}
interface UseModuleMenuItemsProps {
@@ -73,7 +75,7 @@ type MenuResult = {
export const useCycleMenuItems = (props: UseCycleMenuItemsProps): MenuResult => {
const factory = useQuickActionsFactory();
const { cycleDetails, isEditingAllowed, workspaceSlug, projectId, cycleId, ...handlers } = props;
const { cycleDetails, isEditingAllowed, isFavorite, workspaceSlug, projectId, cycleId, ...handlers } = props;
const isArchived = !!cycleDetails?.archived_at;
const isCompleted = cycleDetails?.status?.toLowerCase() === "completed";
@@ -103,6 +105,10 @@ export const useCycleMenuItems = (props: UseCycleMenuItemsProps): MenuResult =>
factory.createEditMenuItem(handlers.handleEdit, isEditingAllowed && !isCompleted && !isArchived),
factory.createOpenInNewTabMenuItem(handlers.handleOpenInNewTab),
factory.createCopyLinkMenuItem(handlers.handleCopyLink),
factory.createFavoriteMenuItem(handlers.handleFavorite, {
isFavorite,
shouldRender: isEditingAllowed && !isArchived,
}),
...(endCycleFeature?.items ?? []),
...(exportFeature?.items ?? []),
factory.createArchiveMenuItem(handlers.handleArchive, {
@@ -11,39 +11,37 @@
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { lazy, Suspense, useMemo } from "react";
import { lazy, Suspense } from "react";
import { useFlag } from "@/plane-web/hooks/store";
// Module-level lazy imports so React sees stable component references across renders/remounts.
// This prevents the active-cycle collapsible state from resetting.
const ActiveCycleV1 = lazy(() => import("./v1/root").then((module) => ({ default: module["ActiveCycleRoot"] })));
const ActiveCycleV2 = lazy(() => import("./v2/root").then((module) => ({ default: module["ActiveCycleRoot"] })));
type ProjectActiveCycleRootProps = {
workspaceSlug: string;
projectId: string;
cycleId?: string;
cycleIds?: string[];
showHeader?: boolean;
};
export function ProjectActiveCycleRoot(props: ProjectActiveCycleRootProps) {
const { workspaceSlug, projectId, cycleId, showHeader = true } = props;
const { workspaceSlug, projectId, cycleId, cycleIds, showHeader = true } = props;
// derived values
const isFeatureEnabled = useFlag(workspaceSlug, "CYCLE_PROGRESS_CHARTS");
const ActiveCycle = useMemo(
function ActiveCycle() {
return lazy(() =>
isFeatureEnabled
? import(`./v2/root`).then((module) => ({
default: module["ActiveCycleRoot"],
}))
: import("./v1/root").then((module) => ({
default: module["ActiveCycleRoot"],
}))
);
},
[isFeatureEnabled]
);
const ActiveCycle = isFeatureEnabled ? ActiveCycleV2 : ActiveCycleV1;
return (
<Suspense fallback={<></>}>
<ActiveCycle workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} showHeader={showHeader} />
<ActiveCycle
workspaceSlug={workspaceSlug}
projectId={projectId}
cycleId={cycleId}
cycleIds={cycleIds}
showHeader={showHeader}
/>
</Suspense>
);
}
@@ -36,6 +36,7 @@ type ActiveCycleRootProps = {
workspaceSlug: string;
projectId: string;
cycleId: string | undefined;
cycleIds?: string[];
showHeader?: boolean;
};
@@ -101,23 +102,79 @@ const ActiveCyclesComponent = observer(function ActiveCyclesComponent({
);
});
export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: ActiveCycleRootProps) {
const { workspaceSlug, projectId, cycleId: propsCycleId, showHeader = true } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { currentProjectActiveCycleId } = useCycle();
// derived values
const cycleId = propsCycleId ?? currentProjectActiveCycleId ?? undefined;
// fetch cycle details
// Renders details for a single cycle ID — calls the data-fetching hook internally
const ActiveCycleEntry = observer(function ActiveCycleEntry({
cycleId,
workspaceSlug,
projectId,
}: {
cycleId: string;
workspaceSlug: string;
projectId: string;
}) {
const {
handleFiltersUpdate,
cycle: activeCycle,
cycleIssueDetails,
} = useActiveCycleDetails({ workspaceSlug, projectId, cycleId });
} = useActiveCycleDetails({
workspaceSlug,
projectId,
cycleId,
});
return (
<ActiveCyclesComponent
cycleId={cycleId}
activeCycle={activeCycle}
workspaceSlug={workspaceSlug}
projectId={projectId}
handleFiltersUpdate={handleFiltersUpdate}
cycleIssueDetails={cycleIssueDetails}
/>
);
});
export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: ActiveCycleRootProps) {
const { workspaceSlug, projectId, cycleId: propsCycleId, cycleIds: propsCycleIds, showHeader = true } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { getActiveCycleIds, currentProjectActiveCycleId } = useCycle();
const activeCyclesForProject = getActiveCycleIds(projectId);
// When explicit cycleIds are provided (e.g. from teamspace), use them.
// When a single cycleId is provided (e.g. from cycle detail page), use it.
// Otherwise render all active cycles (supports parallel cycles).
const activeCycleIds =
propsCycleIds && propsCycleIds.length > 0
? propsCycleIds
: propsCycleId
? [propsCycleId]
: activeCyclesForProject.length > 0
? activeCyclesForProject
: currentProjectActiveCycleId
? [currentProjectActiveCycleId]
: [];
const [isOpen, setIsOpen] = useState(true);
const content =
activeCycleIds.length === 0 ? (
<ActiveCyclesComponent
cycleId={undefined}
activeCycle={null}
workspaceSlug={workspaceSlug}
projectId={projectId}
handleFiltersUpdate={() => {}}
/>
) : (
<>
{activeCycleIds.map((id) => (
<ActiveCycleEntry key={id} cycleId={id} workspaceSlug={workspaceSlug} projectId={projectId} />
))}
</>
);
return (
<>
{showHeader ? (
@@ -125,26 +182,10 @@ export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: ActiveCy
<CollapsibleTrigger className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-subtle bg-layer-1 cursor-pointer">
<CycleListGroupHeader title={t("project_cycles.active_cycle.label")} type="current" isExpanded={isOpen} />
</CollapsibleTrigger>
<CollapsibleContent>
<ActiveCyclesComponent
cycleId={cycleId}
activeCycle={activeCycle}
workspaceSlug={workspaceSlug}
projectId={projectId}
handleFiltersUpdate={handleFiltersUpdate}
cycleIssueDetails={cycleIssueDetails}
/>
</CollapsibleContent>
<CollapsibleContent>{content}</CollapsibleContent>
</Collapsible>
) : (
<ActiveCyclesComponent
cycleId={cycleId}
activeCycle={activeCycle}
workspaceSlug={workspaceSlug}
projectId={projectId}
handleFiltersUpdate={handleFiltersUpdate}
cycleIssueDetails={cycleIssueDetails}
/>
content
)}
</>
);
@@ -0,0 +1,206 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import React, { useMemo, useRef } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "react-router";
import { CalendarDays, ChevronRight } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { IconButton } from "@plane/propel/icon-button";
import { CycleGroupIcon, InfoIcon } from "@plane/propel/icons";
import { CircularProgressIndicator } from "@plane/ui";
import { calculateCycleProgress, renderFormattedPayloadDate } from "@plane/utils";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useMember } from "@/hooks/store/use-member";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { useQueryParams } from "@/hooks/use-query-params";
// components
import { ListItem } from "@/components/core/list";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { MergedDateDisplay } from "@/components/dropdowns/merged-date";
import { CycleQuickActions } from "../../quick-actions";
// local imports
import ScopeDelta from "./scope-delta";
import { useActiveCycleDetails } from "./use-active-cycle-details";
import { summaryDataFormatter } from "./helper";
type Props = {
cycleId: string;
workspaceSlug: string;
projectId: string;
onToggle: () => void;
};
export const ActiveCycleCompactRow = observer(function ActiveCycleCompactRow(props: Props) {
const { cycleId, workspaceSlug, projectId, onToggle } = props;
// refs
const parentRef = useRef<HTMLDivElement>(null);
// router
const router = useAppRouter();
const [searchParams] = useSearchParams();
const { updateQueryParams } = useQueryParams();
// store hooks
const { getCycleById } = useCycle();
const { getUserDetails } = useMember();
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
// derived values
const cycleDetails = getCycleById(cycleId);
const createdByDetails = cycleDetails?.created_by ? getUserDetails(cycleDetails.created_by) : undefined;
const cycleProgress = useActiveCycleDetails({
workspaceSlug,
projectId,
cycleId,
});
const progress = cycleDetails ? calculateCycleProgress(cycleDetails) : 0;
const today = renderFormattedPayloadDate(new Date()) ?? "";
const progressData = cycleProgress.cycleProgress;
const dataToday = progressData?.find((d) => d.date === today);
const estimateTypeFormatter = summaryDataFormatter("issues");
// Compute scope delta visibility
const hasScopeDelta = useMemo(() => {
if (!progressData || !dataToday) return false;
const prevIndex = progressData.findIndex((d) => d.date === dataToday.date) - 1;
if (prevIndex < 0) return false;
const prevData = progressData[prevIndex];
return prevData.scope !== dataToday.scope && !!prevData.scope;
}, [progressData, dataToday]);
if (!cycleDetails) return null;
// Compute trailing/leading value
const trailingValue =
dataToday && dataToday.ideal !== undefined && dataToday.actual !== undefined
? Math.abs(dataToday.ideal - dataToday.actual)
: null;
const isBehind = dataToday ? (dataToday.ideal ?? 0) < (dataToday.actual ?? 0) : false;
const openCycleOverview = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.stopPropagation();
e.preventDefault();
const isPeeking = searchParams.get("peekCycle") === cycleId;
const newRoute = isPeeking
? updateQueryParams({ paramsToRemove: ["peekCycle"] })
: updateQueryParams({ paramsToAdd: { peekCycle: cycleId } });
router.push(newRoute);
};
const handleEventPropagation = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};
return (
<ListItem
title={cycleDetails.name}
itemLink={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`}
parentRef={parentRef}
isMobile={isMobile}
className="py-3 text-13 border-b border-subtle bg-layer-transparent hover:bg-layer-transparent-hover"
prependTitleElement={
<>
<IconButton
icon={ChevronRight}
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onToggle();
}}
aria-label="Toggle cycle details"
/>
<CycleGroupIcon cycleGroup="current" height="16" width="16" />
</>
}
appendTitleElement={
<button onClick={openCycleOverview} className={`z-5 shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}>
<InfoIcon className="h-4 w-4 text-placeholder" />
</button>
}
actionableItems={
<div className="flex items-center gap-3 shrink-0" onClick={handleEventPropagation}>
{/* Progress fraction */}
<div className="flex items-center gap-1.5">
<CircularProgressIndicator size={16} percentage={progress} strokeWidth={2} />
<span className="text-11 text-tertiary font-medium">
{progress}% ({cycleDetails.completed_issues}/{cycleDetails.total_issues})
</span>
</div>
{/* Scope delta */}
{hasScopeDelta && (
<>
<div className="h-3 w-px bg-subtle" />
<div className="flex items-center gap-1 text-11">
<span className="text-tertiary">Scope</span>
<ScopeDelta data={progressData} dataToday={dataToday} />
</div>
</>
)}
{/* Trailing/Leading */}
{trailingValue !== null && trailingValue > 0 && (
<>
<div className="h-3 w-px bg-subtle" />
<div className="flex items-center gap-1 text-11">
<span className={isBehind ? "text-danger-primary" : "text-success-primary"}>
{isBehind ? t("project_cycles.active_cycle.trailing") : t("project_cycles.active_cycle.leading")}
</span>
<span className={`font-medium ${isBehind ? "text-danger-primary" : "text-success-primary"}`}>
{estimateTypeFormatter(trailingValue)}
</span>
</div>
</>
)}
{/* Date button */}
{cycleDetails.start_date && cycleDetails.end_date && (
<Button variant="secondary" size="sm" prependIcon={<CalendarDays className="size-3" />} disabled>
<MergedDateDisplay startDate={cycleDetails.start_date} endDate={cycleDetails.end_date} />
</Button>
)}
{/* Lead avatar */}
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails.id} />}
{/* Quick actions menu */}
<div className="hidden md:block">
<CycleQuickActions
parentRef={parentRef}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</div>
</div>
}
quickActionElement={
<div className="block md:hidden">
<CycleQuickActions
parentRef={parentRef}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</div>
}
/>
);
});
@@ -11,36 +11,23 @@
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import type { TCycleProgress } from "@plane/types";
import { CircularProgressIndicator } from "@plane/ui";
type Props = {
progress: Partial<TCycleProgress> | null | undefined;
days_left: number;
};
function ProgressDonut(props: Props) {
const { progress, days_left } = props;
const [isHovering, setIsHovering] = useState<boolean>(false);
const percentage = progress ? ((progress?.completed ?? 0) * 100) / (progress?.scope ?? 1) : 0;
const { progress } = props;
const percentage = progress ? ((progress?.completed ?? 0) * 100) / (progress?.scope || 1) : 0;
return (
<div
className="group flex items-center justify-between py-1 rounded-full"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<CircularProgressIndicator size={65} percentage={percentage} strokeWidth={3}>
<span className="text-[20px] text-accent-secondary font-bold block text-center">
{progress ? (isHovering ? days_left : `${percentage ? percentage.toFixed(0) : 0}%`) : "0%"}
<div className="flex items-center justify-between rounded-full shrink-0">
<CircularProgressIndicator size={48} percentage={percentage} strokeWidth={3}>
<span className="text-xs text-primary font-medium block text-center">
{`${percentage ? percentage.toFixed(0) : 0}%`}
</span>
{isHovering && (
<div className="text-accent-secondary text-9 uppercase whitespace-nowrap tracking-[0.9px] font-semibold leading-[11px] text-center">
{days_left === 1 ? "Day" : "Days"} <br /> left
</div>
)}
</CircularProgressIndicator>
</div>
);
@@ -11,19 +11,25 @@
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import type { FC } from "react";
import React, { useRef } from "react";
import { format, startOfToday } from "date-fns";
import { usePathname, useSearchParams } from "next/navigation";
import { CalendarDays } from "lucide-react";
// plane imports
import { Button } from "@plane/propel/button";
import { CycleGroupIcon, InfoIcon } from "@plane/propel/icons";
import { BetaBadge } from "@/components/common/beta";
import { Tooltip } from "@plane/propel/tooltip";
import type { ICycle, TCycleProgress } from "@plane/types";
import { ControlLink, Loader } from "@plane/ui";
import { findHowManyDaysLeft } from "@plane/utils";
import { CycleListItemAction } from "@/components/cycles/list/cycle-list-item-action";
import { generateQueryParams } from "@plane/utils";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { MergedDateDisplay } from "@/components/dropdowns/merged-date";
import { CycleQuickActions } from "@/components/cycles/quick-actions";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useAppRouter } from "@/hooks/use-app-router";
// local imports
import { BetaBadge } from "@/components/common/beta";
import ProgressDonut from "./progress-donut";
type Props = {
@@ -39,11 +45,23 @@ export function CycleProgressHeader(props: Props) {
// router
const router = useAppRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const parentRef = useRef(null);
const progressToday = progress && progress.find((d) => d.date === format(startOfToday(), "yyyy-MM-dd"));
// store hooks
const { getUserDetails } = useMember();
const createdByDetails = cycleDetails.created_by ? getUserDetails(cycleDetails.created_by) : undefined;
// handlers
const handleControlLinkClick = () => {
router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`);
const openCycleOverview = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.stopPropagation();
e.preventDefault();
const query = generateQueryParams(searchParams, ["peekCycle"]);
if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) {
router.push(`${pathname}?${query}`);
} else {
router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`);
}
};
const handleEventPropagation = (e: React.MouseEvent) => {
@@ -53,35 +71,38 @@ export function CycleProgressHeader(props: Props) {
return (
<ControlLink
onClick={handleControlLinkClick}
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`)}
>
<div className="px-page-x flex items-center justify-between py-4 bg-surface-1 w-full">
<div className="flex gap-6 h-full truncate">
{progress === null && <Loader.Item width="65px" height="65px" className="flex-shrink-0 rounded-full" />}
{progress && (
<ProgressDonut progress={progressToday} days_left={findHowManyDaysLeft(cycleDetails.end_date) ?? 0} />
)}
<div className="flex flex-col h-full my-auto w-full overflow-hidden">
<div ref={parentRef} className="group px-page-x flex items-center justify-between py-4 bg-surface-1 w-full">
<div className="flex gap-4 h-full truncate items-center">
{progress === null && <Loader.Item width="48px" height="48px" className="shrink-0 rounded-full" />}
{progress && <ProgressDonut progress={progressToday} />}
<div className="flex flex-col overflow-hidden">
<div className="flex gap-2 items-center">
<div className="text-11 text-accent-secondary font-medium">Currently active cycle</div>
<CycleGroupIcon cycleGroup="current" height="16" width="16" />
<BetaBadge />
</div>
<Tooltip tooltipContent={cycleDetails.name} position="bottom-end">
<div className="inline-block line-clamp-1 truncate font-bold text-primary my-1 text-[20px] text-left">
{cycleDetails.name}
</div>
<span className="truncate font-bold text-primary text-xl">{cycleDetails.name}</span>
</Tooltip>
</div>
</div>
<div className="flex shrink-0 gap-4 items-center" onClick={handleEventPropagation}>
<CycleListItemAction
workspaceSlug={workspaceSlug}
projectId={projectId}
cycleId={cycleId}
cycleDetails={cycleDetails}
<div className="flex shrink-0 gap-3 items-center" onClick={handleEventPropagation}>
<button onClick={openCycleOverview} className="shrink-0 hidden group-hover:flex">
<InfoIcon className="h-4 w-4 text-placeholder" />
</button>
{cycleDetails.start_date && cycleDetails.end_date && (
<Button variant="secondary" size="sm" prependIcon={<CalendarDays className="size-3" />} disabled>
<MergedDateDisplay startDate={cycleDetails.start_date} endDate={cycleDetails.end_date} />
</Button>
)}
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails.id} />}
<CycleQuickActions
parentRef={parentRef}
isActive
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</div>
</div>
@@ -11,31 +11,136 @@
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useEffect, useMemo, useState } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { ChevronDown } from "lucide-react";
// plane imports
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@plane/propel/collapsible";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import { useTranslation } from "@plane/i18n";
import { CycleProgressHeader } from "@/components/cycles/active-cycles/v2/progress-header";
import { CycleListGroupHeader } from "@/components/cycles/list/cycle-list-group-header";
import { useCycle } from "@/hooks/store/use-cycle";
// local imports
import { ActiveCycleDetails } from "./details";
import { ActiveCycleCompactRow } from "./compact-row";
import { useActiveCycleDetails } from "./use-active-cycle-details";
type ActiveCycleRootProps = {
workspaceSlug: string;
projectId: string;
cycleId?: string;
cycleIds?: string[];
showHeader?: boolean;
};
// Renders details for a single explicitly-provided cycle ID
const ActiveCycleEntry = observer(function ActiveCycleEntry({
cycleId,
workspaceSlug,
projectId,
onCollapse,
}: {
cycleId: string;
workspaceSlug: string;
projectId: string;
onCollapse?: () => void;
}) {
const cycleDetails = useActiveCycleDetails({
workspaceSlug,
projectId,
cycleId,
});
if (!cycleDetails.cycle || isEmpty(cycleDetails.cycle)) return null;
return (
<div className="flex flex-shrink-0 flex-col border-b border-subtle-1">
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-subtle-1 bg-layer-1 cursor-pointer">
<div className="flex items-center bg-surface-1">
{onCollapse && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onCollapse();
}}
className="flex items-center justify-center flex-shrink-0 bg-surface-1 pl-2"
>
<ChevronDown className="size-4 text-tertiary" />
</button>
)}
<div className="flex-1 min-w-0">
<CycleProgressHeader
cycleDetails={cycleDetails.cycle}
progress={cycleDetails.cycleProgress}
projectId={projectId}
cycleId={cycleDetails.cycle?.id || ""}
workspaceSlug={workspaceSlug}
/>
</div>
</div>
</div>
<div>
<ActiveCycleDetails {...cycleDetails} />
</div>
</div>
);
});
export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: ActiveCycleRootProps) {
const { workspaceSlug, projectId, cycleId } = props;
const { workspaceSlug, projectId, cycleId: propsCycleId, cycleIds: propsCycleIds } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const cycleDetails = useActiveCycleDetails({ workspaceSlug, projectId, cycleId });
const { getActiveCycleIds, currentProjectActiveCycleId } = useCycle();
const activeCyclesForProject = getActiveCycleIds(projectId);
if (!cycleDetails.cycle || isEmpty(cycleDetails.cycle))
// When explicit cycleIds are provided (e.g. from teamspace), use them.
// When a single cycleId is provided, render only that cycle.
// Otherwise render all active cycles (supports parallel cycles).
const activeCycleIds = useMemo(
() =>
propsCycleIds && propsCycleIds.length > 0
? propsCycleIds
: propsCycleId
? [propsCycleId]
: activeCyclesForProject.length > 0
? activeCyclesForProject
: currentProjectActiveCycleId
? [currentProjectActiveCycleId]
: [],
[propsCycleIds, propsCycleId, activeCyclesForProject, currentProjectActiveCycleId]
);
// Track which cycles are expanded (first one expanded by default, multiple allowed)
const [expandedCycleIds, setExpandedCycleIds] = useState<Set<string>>(() => new Set([activeCycleIds[0]]));
// Collapsible section state for the "Active N" header
const [isSectionOpen, setIsSectionOpen] = useState(true);
// Sync expandedCycleIds only when the actual set of active cycle IDs changes
// (not on every render — activeCycleIds gets a new array reference from MobX each time)
const activeCycleIdsKey = activeCycleIds.join(",");
useEffect(() => {
setExpandedCycleIds((prev) => {
const stillValid = new Set([...prev].filter((id) => activeCycleIds.includes(id)));
if (stillValid.size > 0) return stillValid;
return activeCycleIds.length > 0 ? new Set([activeCycleIds[0]]) : new Set();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeCycleIdsKey]);
const handleToggleCycle = (id: string) => {
setExpandedCycleIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
if (activeCycleIds.length === 0) {
return (
<EmptyStateDetailed
assetKey="cycle"
@@ -44,23 +149,41 @@ export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: ActiveCy
rootClassName="py-10 h-auto"
/>
);
}
// Collapsible "Active N" header with individually expandable/collapsible cycle entries
return (
<>
<div className="flex flex-shrink-0 flex-col border-b border-subtle-1">
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-subtle-1 bg-layer-1 cursor-pointer">
<CycleProgressHeader
cycleDetails={cycleDetails.cycle}
progress={cycleDetails.cycleProgress}
projectId={projectId}
cycleId={cycleDetails.cycle?.id || " "}
workspaceSlug={workspaceSlug}
/>
</div>
<div>
<ActiveCycleDetails {...cycleDetails} />
</div>
</div>
</>
<Collapsible className="flex shrink-0 flex-col" open={isSectionOpen} onOpenChange={setIsSectionOpen}>
<CollapsibleTrigger className="sticky top-0 z-[2] w-full shrink-0 border-b border-subtle bg-layer-1 cursor-pointer">
<CycleListGroupHeader
title={t("project_cycles.active_cycle.label")}
type="current"
count={activeCycleIds.length}
showCount
isExpanded={isSectionOpen}
/>
</CollapsibleTrigger>
<CollapsibleContent>
{activeCycleIds.map((id) =>
expandedCycleIds.has(id) ? (
<ActiveCycleEntry
key={id}
cycleId={id}
workspaceSlug={workspaceSlug}
projectId={projectId}
onCollapse={() => handleToggleCycle(id)}
/>
) : (
<ActiveCycleCompactRow
key={id}
cycleId={id}
workspaceSlug={workspaceSlug}
projectId={projectId}
onToggle={() => handleToggleCycle(id)}
/>
)
)}
</CollapsibleContent>
</Collapsible>
);
});
@@ -32,11 +32,11 @@ function ScopeDelta(props: Props) {
<div className="flex text-indigo-400 font-medium">
{prevData.scope < dataToday.scope! ? (
<>
<TrendingUp className="w-[12px] h-[12px] my-auto mr-1" /> +
<TrendingUp className="size-3 my-auto mr-1" /> +
</>
) : (
<>
<TrendingDown className="w-[14px] h-[14px] my-auto mr-1" /> -
<TrendingDown className="size-3 my-auto mr-1" /> -
</>
)}
{Math.round(delta)}%
@@ -33,17 +33,24 @@ export interface ICyclesView {
export const CyclesView = observer(function CyclesView(props: ICyclesView) {
const { workspaceSlug, projectId } = props;
// store hooks
const { getFilteredCycleIds, getFilteredCompletedCycleIds, loader, currentProjectActiveCycleId } = useCycle();
const { getFilteredCycleIds, getFilteredCompletedCycleIds, getActiveCycleIds } = useCycle();
const { searchQuery } = useCycleFilter();
const { t } = useTranslation();
// derived values
const filteredCycleIds = getFilteredCycleIds(projectId, false);
const filteredCompletedCycleIds = getFilteredCompletedCycleIds(projectId);
const filteredUpcomingCycleIds = (filteredCycleIds ?? []).filter(
(cycleId) => cycleId !== currentProjectActiveCycleId
);
const activeCycleIdSet = new Set(getActiveCycleIds(projectId));
// Only show active cycles that pass the current filters (status, date, search)
const filteredActiveCycleIds: string[] = [];
const filteredUpcomingCycleIds: string[] = [];
for (const cycleId of filteredCycleIds ?? []) {
if (activeCycleIdSet.has(cycleId)) filteredActiveCycleIds.push(cycleId);
else filteredUpcomingCycleIds.push(cycleId);
}
if (loader || !filteredCycleIds) return <CycleModuleListLayoutLoader />;
// Only show loader before initial fetch (filteredCycleIds is null).
// Avoid showing loader on refetch to prevent unmounting CyclesList and resetting collapsible states.
if (!filteredCycleIds) return <CycleModuleListLayoutLoader />;
if (filteredCycleIds.length === 0 && filteredCompletedCycleIds?.length === 0)
return (
@@ -66,6 +73,7 @@ export const CyclesView = observer(function CyclesView(props: ICyclesView) {
return (
<CyclesList
activeCycleIds={filteredActiveCycleIds}
completedCycleIds={filteredCompletedCycleIds ?? []}
upcomingCycleIds={filteredUpcomingCycleIds}
cycleIds={filteredCycleIds}
@@ -1,316 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import type { MouseEvent } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { Eye, ArrowRight, CalendarDays } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { TransferIcon, WorkItemsIcon, MembersPropertyIcon } from "@plane/propel/icons";
import { setPromiseToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { ICycle, TCycleGroups } from "@plane/types";
import { Avatar, AvatarGroup } from "@plane/propel/avatar";
import { FavoriteStar } from "@plane/ui";
import { getDate, getFileURL, generateQueryParams } from "@plane/utils";
// components
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { MergedDateDisplay } from "@/components/dropdowns/merged-date";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useMember } from "@/hooks/store/use-member";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { useTimeZoneConverter } from "@/hooks/use-timezone-converter";
// local imports
import { CycleQuickActions } from "../quick-actions";
import { StartCycleButton } from "../start-cycle/button";
import { TransferIssuesModal } from "../transfer-issues-modal";
type Props = {
workspaceSlug: string;
projectId: string;
cycleId: string;
cycleDetails: ICycle;
parentRef: React.RefObject<HTMLDivElement>;
isActive?: boolean;
};
const defaultValues: Partial<ICycle> = {
start_date: null,
end_date: null,
};
export const CycleListItemAction = observer(function CycleListItemAction(props: Props) {
const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef, isActive = false } = props;
// router
const { projectId: routerProjectId } = useParams();
//states
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
// hooks
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const { isProjectTimeZoneDifferent, getProjectUTCOffset, renderFormattedDateInUserTimezone } =
useTimeZoneConverter(projectId);
// router
const router = useAppRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
// store hooks
const { addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { allowPermissions } = useUserPermissions();
// local storage
const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage<boolean>(
IS_FAVORITE_MENU_OPEN,
false
);
const { getUserDetails } = useMember();
// form
const { reset } = useForm({
defaultValues,
});
// derived values
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
const showIssueCount = useMemo(() => cycleStatus === "draft" || cycleStatus === "upcoming", [cycleStatus]);
const transferableIssuesCount = cycleDetails
? cycleDetails.total_issues - (cycleDetails.cancelled_issues + cycleDetails.completed_issues)
: 0;
const showTransferIssues = routerProjectId && transferableIssuesCount > 0 && cycleStatus === "completed";
const projectUTCOffset = getProjectUTCOffset();
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
// handlers
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId);
setPromiseToast(addToFavoritePromise, {
loading: t("project_cycles.action.favorite.loading"),
success: {
title: t("project_cycles.action.favorite.success.title"),
message: () => t("project_cycles.action.favorite.success.description"),
},
error: {
title: t("project_cycles.action.favorite.failed.title"),
message: () => t("project_cycles.action.favorite.failed.description"),
},
});
};
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const removeFromFavoritePromise = removeCycleFromFavorites(
workspaceSlug?.toString(),
projectId.toString(),
cycleId
);
setPromiseToast(removeFromFavoritePromise, {
loading: t("project_cycles.action.unfavorite.loading"),
success: {
title: t("project_cycles.action.unfavorite.success.title"),
message: () => t("project_cycles.action.unfavorite.success.description"),
},
error: {
title: t("project_cycles.action.unfavorite.failed.title"),
message: () => t("project_cycles.action.unfavorite.failed.description"),
},
});
};
const createdByDetails = cycleDetails.created_by ? getUserDetails(cycleDetails.created_by) : undefined;
useEffect(() => {
if (cycleDetails)
reset({
...cycleDetails,
});
}, [cycleDetails, reset]);
// handlers
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.preventDefault();
e.stopPropagation();
const query = generateQueryParams(searchParams, ["peekCycle"]);
if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) {
router.push(`${pathname}?${query}`);
} else {
router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`);
}
};
return (
<>
<TransferIssuesModal
handleClose={() => setTransferIssuesModal(false)}
isOpen={transferIssuesModal}
cycleId={cycleId.toString()}
/>
<button
onClick={openCycleOverview}
className={`z-[1] flex text-accent-secondary text-11 gap-1 flex-shrink-0 ${isMobile || (isActive && !searchParams.has("peekCycle")) ? "flex" : "hidden group-hover:flex"}`}
>
<Eye className="h-4 w-4 my-auto text-accent-secondary" />
<span>{t("project_cycles.more_details")}</span>
</button>
{showIssueCount && (
<div className="flex items-center gap-1">
<WorkItemsIcon className="h-4 w-4 text-tertiary" />
<span className="text-11 text-tertiary">{cycleDetails.total_issues}</span>
</div>
)}
<StartCycleButton cycleId={cycleId} projectId={projectId} />
{showTransferIssues && (
<div
className="px-2 h-6 text-accent-secondary flex items-center gap-1 cursor-pointer"
onClick={() => {
setTransferIssuesModal(true);
}}
>
<TransferIcon className="fill-accent-primary w-4" />
<span>{t("project_cycles.transfer_work_items", { count: transferableIssuesCount })}</span>
</div>
)}
{isActive ? (
<>
<div className="flex gap-2">
{/* Duration */}
<Tooltip
tooltipContent={
<span className="flex gap-1">
{renderFormattedDateInUserTimezone(cycleDetails.start_date ?? "")}
<ArrowRight className="h-3 w-3 flex-shrink-0 my-auto" />
{renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
</span>
}
disabled={!isProjectTimeZoneDifferent()}
tooltipHeading={t("project_cycles.in_your_timezone")}
>
<div className="flex gap-1 text-11 text-tertiary font-medium items-center">
<CalendarDays className="h-3 w-3 flex-shrink-0 my-auto" />
<MergedDateDisplay startDate={cycleDetails.start_date} endDate={cycleDetails.end_date} />
</div>
</Tooltip>
{projectUTCOffset && (
<span className="rounded-md text-11 px-2 cursor-default py-1 bg-layer-1 text-tertiary">
{projectUTCOffset}
</span>
)}
{/* created by */}
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
</div>
</>
) : (
cycleDetails.start_date && (
<>
<DateRangeDropdown
buttonVariant={"transparent-with-text"}
buttonContainerClassName={`h-6 w-full cursor-auto flex items-center gap-1.5 text-tertiary rounded-sm text-11 [&>div]:hover:bg-transparent`}
buttonClassName="p-0"
minDate={new Date()}
value={{
from: getDate(cycleDetails.start_date),
to: getDate(cycleDetails.end_date),
}}
placeholder={{
from: t("project_cycles.start_date"),
to: t("project_cycles.end_date"),
}}
showTooltip={isProjectTimeZoneDifferent()}
customTooltipHeading={t("project_cycles.in_your_timezone")}
customTooltipContent={
<span className="flex gap-1">
{renderFormattedDateInUserTimezone(cycleDetails.start_date ?? "")}
<ArrowRight className="h-3 w-3 flex-shrink-0 my-auto" />
{renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
</span>
}
mergeDates
required={cycleDetails.status !== "draft"}
disabled
hideIcon={{
from: false,
to: false,
}}
/>
</>
)
)}
{/* created by */}
{createdByDetails && !isActive && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
{!isActive && (
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
<div className="flex w-min 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={getFileURL(member?.avatar_url ?? "")} />
);
})}
</AvatarGroup>
) : (
<MembersPropertyIcon className="h-4 w-4 text-tertiary" />
)}
</div>
</Tooltip>
)}
{isEditingAllowed && !cycleDetails.archived_at && (
<FavoriteStar
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
else handleAddToFavorites(e);
}}
selected={!!cycleDetails.is_favorite}
/>
)}
<div className="hidden md:block">
<CycleQuickActions
parentRef={parentRef}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</div>
</>
);
});
@@ -11,48 +11,47 @@
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import type { MouseEvent } from "react";
import { useRef } from "react";
import React, { useRef } from "react";
import { observer } from "mobx-react";
import { usePathname, useSearchParams } from "next/navigation";
import { CheckIcon } from "@plane/propel/icons";
// plane imports
import { CycleGroupIcon, InfoIcon } from "@plane/propel/icons";
import type { TCycleGroups } from "@plane/types";
import { CircularProgressIndicator } from "@plane/ui";
import { generateQueryParams } from "@plane/utils";
// components
import { generateQueryParams, calculateCycleProgress } from "@plane/utils";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { ListItem } from "@/components/core/list";
import { MergedDateDisplay } from "@/components/dropdowns/merged-date";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useMember } from "@/hooks/store/use-member";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { CycleQuickActions } from "../quick-actions";
import { CycleListItemAction } from "./cycle-list-item-action";
import { StartCycleButton } from "../start-cycle/button";
import { Button } from "@plane/propel/button";
import { CalendarDays } from "lucide-react";
type TCyclesListItem = {
cycleId: string;
handleEditCycle?: () => void;
handleDeleteCycle?: () => void;
handleAddToFavorites?: () => void;
handleRemoveFromFavorites?: () => void;
workspaceSlug: string;
projectId: string;
className?: string;
};
export const CyclesListItem = observer(function CyclesListItem(props: TCyclesListItem) {
const { cycleId, workspaceSlug, projectId, className = "" } = props;
const { cycleId, workspaceSlug, projectId, className } = props;
// refs
const parentRef = useRef(null);
const parentRef = useRef<HTMLDivElement>(null);
// router
const router = useAppRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
// hooks
const { isMobile } = usePlatformOS();
const searchParams = useSearchParams();
// store hooks
const { getCycleById } = useCycle();
const { getUserDetails } = useMember();
const { isMobile } = usePlatformOS();
// derived values
const cycleDetails = getCycleById(cycleId);
@@ -60,15 +59,12 @@ export const CyclesListItem = observer(function CyclesListItem(props: TCyclesLis
if (!cycleDetails) return null;
// computed
// TODO: change this logic once backend fix the response
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
const isActive = cycleStatus === "current";
const createdByDetails = cycleDetails.created_by ? getUserDetails(cycleDetails.created_by) : undefined;
// handlers
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.preventDefault();
const openCycleOverview = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.stopPropagation();
e.preventDefault();
const query = generateQueryParams(searchParams, ["peekCycle"]);
if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) {
router.push(`${pathname}?${query}`);
@@ -77,39 +73,46 @@ export const CyclesListItem = observer(function CyclesListItem(props: TCyclesLis
}
};
// handlers
const handleArchivedCycleClick = (e: MouseEvent<HTMLAnchorElement>) => {
const handleArchivedCycleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
openCycleOverview(e);
};
const handleItemClick = cycleDetails.archived_at ? handleArchivedCycleClick : undefined;
const progress = calculateCycleProgress(cycleDetails);
return (
<ListItem
title={cycleDetails?.name ?? ""}
title={cycleDetails.name}
itemLink={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
onItemClick={handleItemClick}
className={className}
prependTitleElement={
<CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
{progress === 100 ? (
<CheckIcon className="h-3 w-3 stroke-2" />
) : (
<span className="text-9 text-primary">{`${progress}%`}</span>
)}
</CircularProgressIndicator>
prependTitleElement={<CycleGroupIcon cycleGroup={cycleStatus} height="16" width="16" />}
appendTitleElement={
<>
<button
onClick={openCycleOverview}
className={`z-5 shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
>
<InfoIcon className="h-4 w-4 text-placeholder" />
</button>
</>
}
actionableItems={
<CycleListItemAction
workspaceSlug={workspaceSlug}
projectId={projectId}
cycleId={cycleId}
cycleDetails={cycleDetails}
parentRef={parentRef}
isActive={isActive}
/>
<>
<StartCycleButton cycleId={cycleId} projectId={projectId} />
{cycleDetails.start_date && cycleDetails.end_date && (
<Button variant="secondary" size="sm" prependIcon={<CalendarDays className="size-3" />} disabled>
<MergedDateDisplay startDate={cycleDetails.start_date} endDate={cycleDetails.end_date} />
</Button>
)}
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails.id} />}
<div className="hidden md:block">
<CycleQuickActions
parentRef={parentRef}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</div>
</>
}
quickActionElement={
<div className="block md:hidden">
@@ -123,7 +126,7 @@ export const CyclesListItem = observer(function CyclesListItem(props: TCyclesLis
}
isMobile={isMobile}
parentRef={parentRef}
isSidebarOpen={searchParams.has("peekCycle")}
className={className}
/>
);
});
+30 -6
View File
@@ -25,6 +25,7 @@ import { CycleListGroupHeader } from "./cycle-list-group-header";
import { CyclesListMap } from "./cycles-list-map";
export interface ICyclesList {
activeCycleIds?: string[];
completedCycleIds: string[];
upcomingCycleIds?: string[] | undefined;
cycleIds: string[];
@@ -37,16 +38,19 @@ const UpcomingCyclesCollapsible = observer(function UpcomingCyclesCollapsible({
upcomingCycleIds,
projectId,
workspaceSlug,
isOpen,
onOpenChange,
}: {
upcomingCycleIds: string[];
projectId: string;
workspaceSlug: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(true);
return (
<Collapsible className="flex flex-shrink-0 flex-col" open={isOpen} onOpenChange={setIsOpen}>
<Collapsible className="flex flex-shrink-0 flex-col" open={isOpen} onOpenChange={onOpenChange}>
<CollapsibleTrigger className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-subtle bg-layer-1 cursor-pointer">
<CycleListGroupHeader
title={t("project_cycles.upcoming_cycle.label")}
@@ -67,16 +71,19 @@ const CompletedCyclesCollapsible = observer(function CompletedCyclesCollapsible(
completedCycleIds,
projectId,
workspaceSlug,
isOpen,
onOpenChange,
}: {
completedCycleIds: string[];
projectId: string;
workspaceSlug: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
return (
<Collapsible className="flex flex-shrink-0 flex-col pb-7" open={isOpen} onOpenChange={setIsOpen}>
<Collapsible className="flex flex-shrink-0 flex-col pb-7" open={isOpen} onOpenChange={onOpenChange}>
<CollapsibleTrigger className="sticky top-0 z-2 w-full flex-shrink-0 border-b border-subtle bg-layer-1 cursor-pointer">
<CycleListGroupHeader
title={t("project_cycles.completed_cycle.label")}
@@ -94,7 +101,18 @@ const CompletedCyclesCollapsible = observer(function CompletedCyclesCollapsible(
});
export const CyclesList = observer(function CyclesList(props: ICyclesList) {
const { completedCycleIds, upcomingCycleIds, cycleIds, workspaceSlug, projectId, isArchived = false } = props;
const {
activeCycleIds,
completedCycleIds,
upcomingCycleIds,
cycleIds,
workspaceSlug,
projectId,
isArchived = false,
} = props;
// Lift collapsible state here so it persists across child remounts
const [isUpcomingOpen, setIsUpcomingOpen] = useState(true);
const [isCompletedOpen, setIsCompletedOpen] = useState(false);
return (
<ContentWrapper variant={ERowVariant.HUGGING} className="flex-row">
@@ -105,18 +123,24 @@ export const CyclesList = observer(function CyclesList(props: ICyclesList) {
</>
) : (
<>
<ProjectActiveCycleRoot workspaceSlug={workspaceSlug} projectId={projectId} />
{activeCycleIds && activeCycleIds.length > 0 && (
<ProjectActiveCycleRoot workspaceSlug={workspaceSlug} projectId={projectId} cycleIds={activeCycleIds} />
)}
{upcomingCycleIds && (
<UpcomingCyclesCollapsible
upcomingCycleIds={upcomingCycleIds}
projectId={projectId}
workspaceSlug={workspaceSlug}
isOpen={isUpcomingOpen}
onOpenChange={setIsUpcomingOpen}
/>
)}
<CompletedCyclesCollapsible
completedCycleIds={completedCycleIds}
projectId={projectId}
workspaceSlug={workspaceSlug}
isOpen={isCompletedOpen}
onOpenChange={setIsCompletedOpen}
/>
</>
)}
@@ -18,7 +18,7 @@ import { MoreHorizontal } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IconButton } from "@plane/propel/icon-button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { TOAST_TYPE, setToast, setPromiseToast } from "@plane/propel/toast";
import type { TContextMenuItem } from "@plane/ui";
import { ContextMenu, CustomMenu } from "@plane/ui";
import { copyUrlToClipboard, cn } from "@plane/utils";
@@ -50,7 +50,7 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
const [deleteModal, setDeleteModal] = useState(false);
// store hooks
const { allowPermissions } = useUserPermissions();
const { getCycleById, restoreCycle } = useCycle();
const { getCycleById, restoreCycle, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { t } = useTranslation();
// derived values
const cycleDetails = getCycleById(cycleId);
@@ -73,6 +73,37 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
});
const handleOpenInNewTab = () => window.open(`/${cycleLink}`, "_blank");
const handleFavoriteToggle = () => {
if (!workspaceSlug || !projectId) return;
if (cycleDetails?.is_favorite) {
const promise = removeCycleFromFavorites(workspaceSlug, projectId, cycleId);
setPromiseToast(promise, {
loading: t("project_cycles.action.unfavorite.loading"),
success: {
title: t("project_cycles.action.unfavorite.success.title"),
message: () => t("project_cycles.action.unfavorite.success.description"),
},
error: {
title: t("project_cycles.action.unfavorite.failed.title"),
message: () => t("project_cycles.action.unfavorite.failed.description"),
},
});
} else {
const promise = addCycleToFavorites(workspaceSlug, projectId, cycleId);
setPromiseToast(promise, {
loading: t("project_cycles.action.favorite.loading"),
success: {
title: t("project_cycles.action.favorite.success.title"),
message: () => t("project_cycles.action.favorite.success.description"),
},
error: {
title: t("project_cycles.action.favorite.failed.title"),
message: () => t("project_cycles.action.favorite.failed.description"),
},
});
}
};
const handleRestoreCycle = async () =>
await restoreCycle(workspaceSlug, projectId, cycleId)
.then(() => {
@@ -97,12 +128,14 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
projectId,
cycleId,
isEditingAllowed,
isFavorite: !!cycleDetails?.is_favorite,
handleEdit: () => setUpdateModal(true),
handleArchive: () => setArchiveCycleModal(true),
handleRestore: handleRestoreCycle,
handleDelete: () => setDeleteModal(true),
handleCopyLink: handleCopyText,
handleOpenInNewTab,
handleFavorite: handleFavoriteToggle,
});
const MENU_ITEMS: TContextMenuItem[] = Array.isArray(menuResult) ? menuResult : menuResult.items;
@@ -147,8 +180,9 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
)}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu
customButton={<IconButton variant="tertiary" size="lg" icon={MoreHorizontal} />}
customButton={<IconButton variant="tertiary" size="base" icon={MoreHorizontal} />}
placement="bottom-end"
portalElement={document.body}
closeOnSelect
maxHeight="lg"
buttonClassName={customClassName}
@@ -12,3 +12,4 @@
*/
export * from "./auto-schedule-cycles";
export * from "./parallel-cycles";
@@ -0,0 +1,86 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { observer } from "mobx-react";
// plane imports
import { UpgradeIcon } from "@plane/propel/icons";
import { setPromiseToast } from "@plane/propel/toast";
import { Button } from "@plane/propel/button";
import { Switch } from "@plane/propel/switch";
import { cn } from "@plane/utils";
// components
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
// plane web imports
import { useFlag, useWorkspaceSubscription } from "@/plane-web/hooks/store";
import { useProjectAdvanced } from "@/plane-web/hooks/store/projects/use-projects";
type Props = {
disabled: boolean;
workspaceSlug: string;
projectId: string;
};
export const ParallelCycles = observer(function ParallelCycles(props: Props) {
const { disabled, workspaceSlug, projectId } = props;
// store hooks
const { isProjectFeatureEnabled, toggleProjectFeatures } = useProjectAdvanced();
const { togglePaidPlanModal } = useWorkspaceSubscription();
// derived values
const isFeatureFlagEnabled = useFlag(workspaceSlug.toString(), "PARALLEL_CYCLES");
const isParallelCyclesEnabled = isProjectFeatureEnabled(projectId.toString(), "is_parallel_cycles_enabled");
const toggleParallelCycles = async (enabled: boolean) => {
const promise = toggleProjectFeatures(workspaceSlug.toString(), projectId.toString(), {
is_parallel_cycles_enabled: enabled,
});
setPromiseToast(promise, {
loading: enabled ? "Enabling parallel cycles" : "Disabling parallel cycles",
success: {
title: "Success",
message: () => (enabled ? "Parallel cycles enabled." : "Parallel cycles disabled."),
},
error: {
title: "Error",
message: () => "Failed to update parallel cycles setting. Please try again.",
},
});
};
return (
<div
className={cn({
"opacity-60 pointer-events-none select-none": disabled && isFeatureFlagEnabled,
})}
>
<SettingsBoxedControlItem
title="Parallel cycles"
description="Run multiple cycles simultaneously to manage overlapping work streams."
control={
isFeatureFlagEnabled ? (
<Switch value={isParallelCyclesEnabled} onChange={toggleParallelCycles} />
) : (
<Button
variant="secondary"
size="lg"
prependIcon={<UpgradeIcon />}
onClick={() => togglePaidPlanModal(true)}
>
Upgrade
</Button>
)
}
/>
</div>
);
});
@@ -27,7 +27,6 @@ export function StartCycleModal(props: Props) {
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.MD}>
<div className="p-4">
<h3 className="text-16 font-medium">Sure you want to start this cycle?</h3>
{/* <p className="text-13 text-tertiary mt-1"></p> */}
<div className="mt-2 pt-2 flex items-center justify-end gap-2 border-t-[0.5px] border-subtle-1">
<Button variant="secondary" onClick={handleClose}>
Cancel
@@ -13,6 +13,7 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { useParams } from "next/navigation";
import { AlertCircle } from "lucide-react";
import { SearchIcon, CycleIcon, TransferIcon, CloseIcon } from "@plane/propel/icons";
@@ -34,6 +35,7 @@ export const TransferIssuesModal = observer(function TransferIssuesModal(props:
const [query, setQuery] = useState("");
// store hooks
const { t } = useTranslation();
const { currentProjectIncompleteCycleIds, getCycleById, fetchActiveCycleProgress } = useCycle();
const {
issues: { transferIssuesFromCycle },
@@ -138,9 +140,7 @@ export const TransferIssuesModal = observer(function TransferIssuesModal(props:
) : (
<div className="flex w-full items-center justify-center gap-4 p-5 text-13">
<AlertCircle className="h-3.5 w-3.5 text-secondary" />
<span className="text-center text-secondary">
You dont have any current cycle. Please create one to transfer the work items.
</span>
<span className="text-center text-secondary">{t("project_cycles.transfer.no_cycles_available")}</span>
</div>
)
) : (
@@ -35,11 +35,11 @@ type TTeamCurrentCyclesRoot = {
const ProjectActiveCycleCollapsible = observer(function ProjectActiveCycleCollapsible({
projectId,
cycleId,
cycleIds,
workspaceSlug,
}: {
projectId: string;
cycleId: string;
cycleIds: string[];
workspaceSlug: string;
}) {
const [isOpen, setIsOpen] = useState(true);
@@ -53,7 +53,7 @@ const ProjectActiveCycleCollapsible = observer(function ProjectActiveCycleCollap
<ProjectActiveCycleRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
cycleId={cycleId}
cycleIds={cycleIds}
showHeader={false}
/>
</CollapsibleContent>
@@ -88,11 +88,11 @@ export const TeamCurrentCyclesRoot = observer(function TeamCurrentCyclesRoot(pro
return (
<ContentWrapper variant={ERowVariant.HUGGING} className="relative">
{Object.entries(groupedActiveCycleIds).map(([projectId, cycleId]) => (
{Object.entries(groupedActiveCycleIds).map(([projectId, cycleIds]) => (
<ProjectActiveCycleCollapsible
key={projectId}
projectId={projectId}
cycleId={cycleId}
cycleIds={cycleIds}
workspaceSlug={workspaceSlug}
/>
))}
+13 -1
View File
@@ -51,6 +51,7 @@ export interface ICycleStore {
currentProjectCompletedCycleIds: string[] | null;
currentProjectIncompleteCycleIds: string[] | null;
currentProjectActiveCycleId: string | null;
getActiveCycleIds: (projectId: string) => string[];
currentProjectArchivedCycleIds: string[] | null;
currentProjectActiveCycle: ICycle | null;
@@ -218,7 +219,7 @@ export abstract class CycleStore implements ICycleStore {
}
/**
* returns active cycle id for a project
* returns active cycle id for a project (first one found — kept for backwards compatibility)
*/
get currentProjectActiveCycleId() {
const projectId = this.rootStore.router.projectId;
@@ -231,6 +232,17 @@ export abstract class CycleStore implements ICycleStore {
return activeCycle || null;
}
/**
* returns all active cycle ids for a project — supports parallel cycles, sorted by start_date ascending
*/
getActiveCycleIds = computedFn((projectId: string): string[] => {
if (!projectId) return [];
const activeCycles = Object.values(this.cycleMap ?? {}).filter(
(cycle) => cycle.project_id === projectId && cycle.status?.toLowerCase() === "current"
);
return sortBy(activeCycles, [(c) => c.start_date]).map((c) => c.id);
});
/**
* returns all archived cycle ids for a project
*/
@@ -114,6 +114,7 @@ export class ProjectDetailsStore implements IProjectDetailsStore {
is_milestone_enabled: false,
project_id: projectId,
is_automated_cycle_enabled: false,
is_parallel_cycles_enabled: false,
};
}
}
@@ -11,7 +11,7 @@
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { set, update } from "lodash-es";
import { set, sortBy, update } from "lodash-es";
import { observable, action, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// types
@@ -32,7 +32,7 @@ export interface ITeamspaceCycleStore {
getTeamspaceFilteredActiveCycleIds: (teamspaceId: string) => string[];
getTeamspaceFilteredCompletedCycleIds: (teamspaceId: string) => string[];
getTeamspaceFilteredUpcomingCycleIds: (teamspaceId: string) => string[];
getTeamspaceGroupedActiveCycleIds: (teamspaceId: string) => Record<string, string>; // project_id -> teamspace active cycle ID
getTeamspaceGroupedActiveCycleIds: (teamspaceId: string) => Record<string, string[]>; // project_id -> teamspace active cycle IDs
getTeamspaceGroupedUpcomingCycleIds: (teamspaceId: string) => Record<string, string[]>; // project_id -> teamspace upcoming cycle IDs
getTeamspaceGroupedCompletedCycleIds: (teamspaceId: string) => Record<string, string[]>; // project_id -> teamspace completed cycle IDs
// actions
@@ -139,19 +139,29 @@ export class TeamspaceCycleStore implements ITeamspaceCycleStore {
/**
* Returns teamspace grouped active cycle IDs
* @param teamspaceId
* @returns Record<string, string> project_id -> teamspace active cycle ID
* @returns Record<string, string[]> project_id -> teamspace active cycle IDs
*/
getTeamspaceGroupedActiveCycleIds = computedFn((teamspaceId: string): Record<string, string> => {
getTeamspaceGroupedActiveCycleIds = computedFn((teamspaceId: string): Record<string, string[]> => {
const teamActiveCycleIds = this.getTeamspaceFilteredActiveCycleIds(teamspaceId);
// Group by project_id -> cycle_id, only one active cycle per project
const projectGroupedActiveCycleIds: Record<string, string> = {};
// Group by project_id -> cycle_ids (supports parallel cycles)
const projectGroupedActiveCycleIds: Record<string, string[]> = {};
teamActiveCycleIds.forEach((cycleId) => {
const cycle = this.cycleStore.getCycleById(cycleId);
const projectId = cycle?.project_id;
if (projectId) {
projectGroupedActiveCycleIds[projectId] = cycleId;
if (!projectGroupedActiveCycleIds[projectId]) {
projectGroupedActiveCycleIds[projectId] = [];
}
projectGroupedActiveCycleIds[projectId].push(cycleId);
}
});
// Sort each project's cycles by start_date ascending
for (const projectId of Object.keys(projectGroupedActiveCycleIds)) {
const cycles = projectGroupedActiveCycleIds[projectId]
.map((id) => this.cycleStore.getCycleById(id))
.filter((c): c is ICycle => !!c);
projectGroupedActiveCycleIds[projectId] = sortBy(cycles, [(c) => c.start_date]).map((c) => c.id);
}
return projectGroupedActiveCycleIds;
});
+30 -6
View File
@@ -58,6 +58,7 @@ export interface ICycleStore extends ICeCycleStore {
updateCycleStatus: (workspaceSlug: string, projectId: string, cycleId: string, action: CYCLE_ACTION) => Promise<void>;
isNextCycle: (projectId: string, cycleId: string) => boolean;
isParallelCyclesEnabled: (workspaceSlug: string, projectId: string) => boolean;
}
export class CycleStore extends CeCycleStore implements ICycleStore {
@@ -89,6 +90,15 @@ export class CycleStore extends CeCycleStore implements ICycleStore {
this.cycleService = new CycleService();
}
isParallelCyclesEnabled = computedFn((workspaceSlug: string, projectId: string) => {
const isFeatureFlagEnabled = this.store.featureFlags.getFeatureFlag(workspaceSlug, "PARALLEL_CYCLES", false);
const isProjectFeatureEnabled = this.store.projectDetails.isProjectFeatureEnabled(
projectId,
"is_parallel_cycles_enabled"
);
return isProjectFeatureEnabled && isFeatureFlagEnabled;
});
fetchUpdates = async (workspaceSlug: string, projectId: string, cycleId: string) => {
const updates = await this.cycleUpdateService.getCycleUpdates(workspaceSlug, projectId, cycleId);
runInAction(() => {
@@ -229,15 +239,29 @@ export class CycleStore extends CeCycleStore implements ICycleStore {
};
isNextCycle = computedFn((projectId: string, cycleId: string) => {
//check for an active cycle
const activeCycle = Object.values(this.cycleMap ?? {}).find(
(c) => c.project_id === projectId && c.status?.toLowerCase() === "current"
);
if (activeCycle) return false;
// filter cycles with status "upcoming" return one with the latest start date
// When parallel cycles are enabled (feature flag + project setting), any upcoming cycle can be started
const workspaceSlug = this.store.router.workspaceSlug;
const allowParallel = workspaceSlug ? this.isParallelCyclesEnabled(workspaceSlug, projectId) : false;
if (!allowParallel) {
// Default behaviour: block start if any active cycle already exists
const activeCycle = Object.values(this.cycleMap ?? {}).find(
(c) => c.project_id === projectId && c.status?.toLowerCase() === "current"
);
if (activeCycle) return false;
}
// In parallel mode (or when no active cycle exists): return true for any upcoming cycle
const upcomingCycles = Object.values(this.cycleMap ?? {}).filter(
(c) => c.project_id === projectId && c.status?.toLowerCase() === "upcoming"
);
if (allowParallel) {
// Any upcoming cycle can be started — show Start button for all of them
return upcomingCycles.some((c) => c.id === cycleId);
}
// Default: only the earliest upcoming cycle can be started
const nextCycle = sortBy(upcomingCycles, [(c) => c.start_date])[0];
return nextCycle?.id === cycleId;
});
+2
View File
@@ -54,6 +54,7 @@ export enum E_FEATURE_FLAGS {
ADVANCED_EXPORTS = "ADVANCED_EXPORTS",
MILESTONES = "MILESTONES",
AUTO_SCHEDULE_CYCLES = "AUTO_SCHEDULE_CYCLES",
PARALLEL_CYCLES = "PARALLEL_CYCLES",
WORKSPACE_MEMBERS_IMPORT = "WORKSPACE_MEMBERS_IMPORT",
// ====== silo importers ======
@@ -189,6 +190,7 @@ export const FEATURE_TO_BASE_PLAN_MAP = {
[E_FEATURE_FLAGS.PROJECT_AUTOMATIONS]: EProductSubscriptionEnum.BUSINESS,
[E_FEATURE_FLAGS.WORKITEM_TYPE_FORMULA_FIELD]: EProductSubscriptionEnum.BUSINESS,
[E_FEATURE_FLAGS.IDP_GROUP_SYNC]: EProductSubscriptionEnum.ENTERPRISE,
[E_FEATURE_FLAGS.PARALLEL_CYCLES]: EProductSubscriptionEnum.ENTERPRISE,
};
export type TSupportedFlagsForUpgrade = keyof typeof FEATURE_TO_BASE_PLAN_MAP;
@@ -2707,6 +2707,9 @@ Vytvořte nový.`,
end_date: "Konec data",
in_your_timezone: "V časovém pásmu",
transfer_work_items: "Převést {count} pracovních položek",
transfer: {
no_cycles_available: "Žádné jiné cykly nejsou k dispozici pro přenos pracovních položek.",
},
date_range: "Období data",
add_date: "Přidat datum",
active_cycle: {
@@ -2719,6 +2722,8 @@ Vytvořte nový.`,
ideal: "Ideální",
current: "Aktuální",
labels: "Štítky",
trailing: "Zpoždění",
leading: "Předstih",
},
upcoming_cycle: {
label: "Nadcházející cyklus",
@@ -2738,6 +2738,9 @@ Erstellen Sie ein neues.`,
end_date: "Enddatum",
in_your_timezone: "In Ihrer Zeitzone",
transfer_work_items: "Übertragen von {count} Arbeitselementen",
transfer: {
no_cycles_available: "Keine anderen Zyklen verfügbar, um Arbeitselemente zu übertragen.",
},
date_range: "Datumsbereich",
add_date: "Datum hinzufügen",
active_cycle: {
@@ -2750,6 +2753,8 @@ Erstellen Sie ein neues.`,
ideal: "Ideal",
current: "Aktuell",
labels: "Labels",
trailing: "Rückstand",
leading: "Vorsprung",
},
upcoming_cycle: {
label: "Bevorstehender Zyklus",
@@ -2796,6 +2796,9 @@ Create a new project instead`,
end_date: "End date",
in_your_timezone: "In your timezone",
transfer_work_items: "Transfer {count} work items",
transfer: {
no_cycles_available: "No other cycles available to transfer work items to.",
},
date_range: "Date range",
add_date: "Add date",
active_cycle: {
@@ -2808,6 +2811,8 @@ Create a new project instead`,
ideal: "Ideal",
current: "Current",
labels: "Labels",
trailing: "Trailing",
leading: "Leading",
},
upcoming_cycle: {
label: "Upcoming cycle",
@@ -2749,6 +2749,9 @@ Crea un nuevo proyecto en su lugar`,
end_date: "Fecha de finalización",
in_your_timezone: "En tu zona horaria",
transfer_work_items: "Transferir {count} elementos de trabajo",
transfer: {
no_cycles_available: "No hay otros ciclos disponibles para transferir elementos de trabajo.",
},
date_range: "Rango de fechas",
add_date: "Agregar fecha",
active_cycle: {
@@ -2761,6 +2764,8 @@ Crea un nuevo proyecto en su lugar`,
ideal: "Ideal",
current: "Actual",
labels: "Etiquetas",
trailing: "Retrasado",
leading: "Adelantado",
},
upcoming_cycle: {
label: "Ciclo próximo",
@@ -2755,6 +2755,9 @@ Créez plutôt un nouveau projet`,
end_date: "Date de fin",
in_your_timezone: "Dans votre fuseau horaire",
transfer_work_items: "Transférer {count} éléments de travail",
transfer: {
no_cycles_available: "Aucun autre cycle disponible pour transférer les éléments de travail.",
},
date_range: "Plage de dates",
add_date: "Ajouter une date",
active_cycle: {
@@ -2767,6 +2770,8 @@ Créez plutôt un nouveau projet`,
ideal: "Idéal",
current: "Actuel",
labels: "Étiquettes",
trailing: "En retard",
leading: "En avance",
},
upcoming_cycle: {
label: "Cycle à venir",
@@ -2719,6 +2719,10 @@ Buat proyek baru sebagai gantinya`,
remove_filters_to_see_all_cycles: "Hapus filter untuk melihat semua siklus",
remove_search_criteria_to_see_all_cycles: "Hapus kriteria pencarian untuk melihat semua siklus",
only_completed_cycles_can_be_archived: "Hanya siklus yang diselesaikan yang dapat diarsipkan",
transfer_work_items: "Transfer {count} item kerja",
transfer: {
no_cycles_available: "Tidak ada siklus lain yang tersedia untuk mentransfer item pekerjaan.",
},
active_cycle: {
label: "Siklus aktif",
progress: "Kemajuan",
@@ -2729,6 +2733,8 @@ Buat proyek baru sebagai gantinya`,
ideal: "Ideal",
current: "Sekarang",
labels: "Label",
trailing: "Tertinggal",
leading: "Memimpin",
},
upcoming_cycle: {
label: "Siklus mendatang",
@@ -2731,6 +2731,9 @@ Crea un nuovo progetto invece`,
end_date: "Data di fine",
in_your_timezone: "Nel tuo fuso orario",
transfer_work_items: "Trasferisci {count} elementi di lavoro",
transfer: {
no_cycles_available: "Nessun altro ciclo disponibile per trasferire gli elementi di lavoro.",
},
date_range: "Intervallo di date",
add_date: "Aggiungi data",
active_cycle: {
@@ -2743,6 +2746,8 @@ Crea un nuovo progetto invece`,
ideal: "Ideale",
current: "Corrente",
labels: "Etichette",
trailing: "In ritardo",
leading: "In anticipo",
},
upcoming_cycle: {
label: "Ciclo in arrivo",
@@ -2708,6 +2708,9 @@ export default {
end_date: "終了日",
in_your_timezone: "あなたのタイムゾーン",
transfer_work_items: "作業項目を転送 {count}",
transfer: {
no_cycles_available: "作業アイテムを転送できる他のサイクルがありません。",
},
date_range: "日付範囲",
add_date: "日付を追加",
active_cycle: {
@@ -2720,6 +2723,8 @@ export default {
ideal: "理想",
current: "現在",
labels: "ラベル",
trailing: "遅れ",
leading: "リード",
},
upcoming_cycle: {
label: "今後のサイクル",
@@ -2689,6 +2689,9 @@ export default {
end_date: "종료일",
in_your_timezone: "내 시간대",
transfer_work_items: "{count}개의 작업 항목 이전",
transfer: {
no_cycles_available: "작업 항목을 전송할 수 있는 다른 사이클이 없습니다.",
},
date_range: "날짜 범위",
add_date: "날짜 추가",
active_cycle: {
@@ -2701,6 +2704,8 @@ export default {
ideal: "이상적인",
current: "현재",
labels: "레이블",
trailing: "뒤처짐",
leading: "앞섬",
},
upcoming_cycle: {
label: "다가오는 주기",
@@ -2716,6 +2716,9 @@ Utwórz nowy.`,
end_date: "Data końca",
in_your_timezone: "W Twojej strefie czasowej",
transfer_work_items: "Przenieś {count} elementów pracy",
transfer: {
no_cycles_available: "Brak innych cykli dostępnych do przeniesienia elementów pracy.",
},
date_range: "Zakres dat",
add_date: "Dodaj datę",
active_cycle: {
@@ -2728,6 +2731,8 @@ Utwórz nowy.`,
ideal: "Idealny",
current: "Obecny",
labels: "Etykiety",
trailing: "Opóźnienie",
leading: "Wyprzedzenie",
},
upcoming_cycle: {
label: "Nadchodzący cykl",
@@ -2738,6 +2738,10 @@ Crie um novo projeto em vez disso`,
remove_filters_to_see_all_cycles: "Remova os filtros para ver todos os ciclos",
remove_search_criteria_to_see_all_cycles: "Remova os critérios de pesquisa para ver todos os ciclos",
only_completed_cycles_can_be_archived: "Apenas ciclos concluídos podem ser arquivados",
transfer_work_items: "Transferir {count} itens de trabalho",
transfer: {
no_cycles_available: "Não há outros ciclos disponíveis para transferir itens de trabalho.",
},
active_cycle: {
label: "Ciclo ativo",
progress: "Progresso",
@@ -2748,6 +2752,8 @@ Crie um novo projeto em vez disso`,
ideal: "Ideal",
current: "Atual",
labels: "Etiquetas",
trailing: "Atrasado",
leading: "Adiantado",
},
upcoming_cycle: {
label: "Próximo ciclo",
@@ -2733,6 +2733,10 @@ Creează un proiect nou.`,
remove_filters_to_see_all_cycles: "Elimină filtrele pentru a vedea toate ciclurile",
remove_search_criteria_to_see_all_cycles: "Elimină criteriile de căutare pentru a vedea toate ciclurile",
only_completed_cycles_can_be_archived: "Doar ciclurile finalizate pot fi arhivate",
transfer_work_items: "Transferați {count} elemente de lucru",
transfer: {
no_cycles_available: "Nu există alte cicluri disponibile pentru a transfera elemente de lucru.",
},
active_cycle: {
label: "Ciclu activ",
progress: "Progres",
@@ -2743,6 +2747,8 @@ Creează un proiect nou.`,
ideal: "Ideal",
current: "Curent",
labels: "Etichete",
trailing: "Întârziat",
leading: "Avansat",
},
upcoming_cycle: {
label: "Ciclu viitor",
@@ -2720,6 +2720,9 @@ export default {
end_date: "Дата окончания",
in_your_timezone: "В вашем часовом поясе",
transfer_work_items: "Перенести {count} рабочих элементов",
transfer: {
no_cycles_available: "Нет других циклов для переноса рабочих элементов.",
},
date_range: "Диапазон дат",
add_date: "Добавить дату",
active_cycle: {
@@ -2732,6 +2735,8 @@ export default {
ideal: "Идеальный",
current: "Текущий",
labels: "Метки",
trailing: "Отставание",
leading: "Опережение",
},
upcoming_cycle: {
label: "Предстоящий цикл",
@@ -2670,6 +2670,9 @@ Vytvorte nový.`,
end_date: "Dátum konca",
in_your_timezone: "Váš časový pásmo",
transfer_work_items: "Presunúť {count} pracovných položiek",
transfer: {
no_cycles_available: "Nie sú k dispozícii žiadne iné cykly na prenos pracovných položiek.",
},
date_range: "Dátumový rozsah",
add_date: "Pridať dátum",
active_cycle: {
@@ -2682,6 +2685,8 @@ Vytvorte nový.`,
ideal: "Ideálne",
current: "Aktuálne",
labels: "Štítky",
trailing: "Oneskorenie",
leading: "Náskok",
},
upcoming_cycle: {
label: "Nadchádzajúci cyklus",
@@ -2709,6 +2709,9 @@ Bunun yerine yeni bir proje oluşturun`,
end_date: "Bitiş tarihi",
in_your_timezone: "Saat diliminizde",
transfer_work_items: "{count} iş öğesini aktar",
transfer: {
no_cycles_available: "İş öğelerini aktaracak başka döngü bulunamadı.",
},
date_range: "Tarih aralığı",
add_date: "Tarih ekle",
active_cycle: {
@@ -2721,6 +2724,8 @@ Bunun yerine yeni bir proje oluşturun`,
ideal: "İdeal",
current: "Mevcut",
labels: "Etiketler",
trailing: "Geride",
leading: "İlerde",
},
upcoming_cycle: {
label: "Yaklaşan döngü",
@@ -2711,6 +2711,9 @@ export default {
end_date: "Дата завершення",
in_your_timezone: "У вашому часовому поясі",
transfer_work_items: "Перенести {count} робочих одиниць",
transfer: {
no_cycles_available: "Немає інших циклів для переміщення робочих елементів.",
},
date_range: "Діапазон дат",
add_date: "Додати дату",
active_cycle: {
@@ -2723,6 +2726,8 @@ export default {
ideal: "Ідеальний",
current: "Поточний",
labels: "Мітки",
trailing: "Відставання",
leading: "Випередження",
},
upcoming_cycle: {
label: "Майбутній цикл",
@@ -2716,6 +2716,9 @@ Tạo dự án mới`,
end_date: "Ngày kết thúc",
in_your_timezone: "Trong múi giờ của bạn",
transfer_work_items: "Chuyển {count} mục công việc",
transfer: {
no_cycles_available: "Không có chu kỳ nào khác để chuyển mục công việc.",
},
date_range: "Khoảng thời gian",
add_date: "Thêm ngày",
active_cycle: {
@@ -2728,6 +2731,8 @@ Tạo dự án mới`,
ideal: "Lý tưởng",
current: "Hiện tại",
labels: "Nhãn",
trailing: "Chậm tiến độ",
leading: "Vượt tiến độ",
},
upcoming_cycle: {
label: "Chu kỳ sắp tới",
@@ -2640,6 +2640,9 @@ export default {
end_date: "结束日期",
in_your_timezone: "在您的时区",
transfer_work_items: "转移 {count} 工作项",
transfer: {
no_cycles_available: "没有其他可用的周期来转移工作项。",
},
date_range: "日期范围",
add_date: "添加日期",
active_cycle: {
@@ -2652,6 +2655,8 @@ export default {
ideal: "理想",
current: "当前",
labels: "标签",
trailing: "落后",
leading: "领先",
},
upcoming_cycle: {
label: "即将到来的周期",
@@ -2661,6 +2661,9 @@ export default {
end_date: "結束日期",
in_your_timezone: "在您的時區",
transfer_work_items: "轉移 {count} 工作事項",
transfer: {
no_cycles_available: "沒有其他可用的週期來轉移工作項目。",
},
date_range: "日期範圍",
add_date: "新增日期",
active_cycle: {
@@ -2673,6 +2676,8 @@ export default {
ideal: "理想",
current: "目前",
labels: "標籤",
trailing: "落後",
leading: "領先",
},
upcoming_cycle: {
label: "即將到來的週期",
@@ -107,6 +107,7 @@ export type TProjectFeaturesList = {
is_workflow_enabled: boolean;
is_milestone_enabled: boolean;
is_automated_cycle_enabled: boolean;
is_parallel_cycles_enabled: boolean;
};
export type TProjectFeatures = {