diff --git a/apps/api/plane/app/views/cycle/base.py b/apps/api/plane/app/views/cycle/base.py
index 72b6be87e9..0a4e4142cd 100644
--- a/apps/api/plane/app/views/cycle/base.py
+++ b/apps/api/plane/app/views/cycle/base.py
@@ -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)
diff --git a/apps/api/plane/app/views/estimate/base.py b/apps/api/plane/app/views/estimate/base.py
index 8a743b0250..3759ae07a5 100644
--- a/apps/api/plane/app/views/estimate/base.py
+++ b/apps/api/plane/app/views/estimate/base.py
@@ -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()
diff --git a/apps/api/plane/ee/bgtasks/cycle_automation_task.py b/apps/api/plane/ee/bgtasks/cycle_automation_task.py
index 1718c4d734..a5bdf6ec02 100644
--- a/apps/api/plane/ee/bgtasks/cycle_automation_task.py
+++ b/apps/api/plane/ee/bgtasks/cycle_automation_task.py
@@ -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,
diff --git a/apps/api/plane/ee/migrations/0074_projectfeature_is_parallel_cycles_enabled.py b/apps/api/plane/ee/migrations/0074_projectfeature_is_parallel_cycles_enabled.py
new file mode 100644
index 0000000000..76e028a779
--- /dev/null
+++ b/apps/api/plane/ee/migrations/0074_projectfeature_is_parallel_cycles_enabled.py
@@ -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),
+ ),
+ ]
diff --git a/apps/api/plane/ee/models/project.py b/apps/api/plane/ee/models/project.py
index 3a1ebb26a7..83acd5b9d0 100644
--- a/apps/api/plane/ee/models/project.py
+++ b/apps/api/plane/ee/models/project.py
@@ -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"
diff --git a/apps/api/plane/ee/serializers/app/project.py b/apps/api/plane/ee/serializers/app/project.py
index 4dac2ce7b8..99d8fcc831 100644
--- a/apps/api/plane/ee/serializers/app/project.py
+++ b/apps/api/plane/ee/serializers/app/project.py
@@ -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 = [
diff --git a/apps/api/plane/ee/views/app/cycle/start_stop.py b/apps/api/plane/ee/views/app/cycle/start_stop.py
index b7736c923d..634554aaed 100644
--- a/apps/api/plane/ee/views/app/cycle/start_stop.py
+++ b/apps/api/plane/ee/views/app/cycle/start_stop.py
@@ -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()
diff --git a/apps/api/plane/payment/flags/flag.py b/apps/api/plane/payment/flags/flag.py
index daf0408dc5..ee5dba0f64 100644
--- a/apps/api/plane/payment/flags/flag.py
+++ b/apps/api/plane/payment/flags/flag.py
@@ -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
diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx
index f310cd2e2d..2a80e9d4b6 100644
--- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx
+++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx
@@ -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")}
/>
-
+
+ {/* Parallel cycles configuration */}
+
- {/* Auto-schedule cycles configuration */}
+ {/* Auto-schedule cycles configuration */}
diff --git a/apps/web/core/components/common/quick-actions/factory.tsx b/apps/web/core/components/common/quick-actions/factory.tsx
index e3f8010dec..e4ff3f7ff7 100644
--- a/apps/web/core/components/common/quick-actions/factory.tsx
+++ b/apps/web/core/components/common/quick-actions/factory.tsx
@@ -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 }
diff --git a/apps/web/core/components/common/quick-actions/helper.tsx b/apps/web/core/components/common/quick-actions/helper.tsx
index 6112088adb..21fde7d337 100644
--- a/apps/web/core/components/common/quick-actions/helper.tsx
+++ b/apps/web/core/components/common/quick-actions/helper.tsx
@@ -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, {
diff --git a/apps/web/core/components/cycles/active-cycles/root.tsx b/apps/web/core/components/cycles/active-cycles/root.tsx
index 323688adea..da7176b875 100644
--- a/apps/web/core/components/cycles/active-cycles/root.tsx
+++ b/apps/web/core/components/cycles/active-cycles/root.tsx
@@ -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 (
>}>
-
+
);
}
diff --git a/apps/web/core/components/cycles/active-cycles/v1/root.tsx b/apps/web/core/components/cycles/active-cycles/v1/root.tsx
index 85e4a8b6b6..2b511efade 100644
--- a/apps/web/core/components/cycles/active-cycles/v1/root.tsx
+++ b/apps/web/core/components/cycles/active-cycles/v1/root.tsx
@@ -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 (
+
+ );
+});
+
+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 ? (
+
{}}
+ />
+ ) : (
+ <>
+ {activeCycleIds.map((id) => (
+
+ ))}
+ >
+ );
+
return (
<>
{showHeader ? (
@@ -125,26 +182,10 @@ export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: ActiveCy
-
-
-
+ {content}
) : (
-
+ content
)}
>
);
diff --git a/apps/web/core/components/cycles/active-cycles/v2/compact-row.tsx b/apps/web/core/components/cycles/active-cycles/v2/compact-row.tsx
new file mode 100644
index 0000000000..3652e097c6
--- /dev/null
+++ b/apps/web/core/components/cycles/active-cycles/v2/compact-row.tsx
@@ -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(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) => {
+ 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 (
+
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ onToggle();
+ }}
+ aria-label="Toggle cycle details"
+ />
+
+ >
+ }
+ appendTitleElement={
+
+ }
+ actionableItems={
+
+ {/* Progress fraction */}
+
+
+
+ {progress}% ({cycleDetails.completed_issues}/{cycleDetails.total_issues})
+
+
+
+ {/* Scope delta */}
+ {hasScopeDelta && (
+ <>
+
+
+ Scope
+
+
+ >
+ )}
+
+ {/* Trailing/Leading */}
+ {trailingValue !== null && trailingValue > 0 && (
+ <>
+
+
+
+ {isBehind ? t("project_cycles.active_cycle.trailing") : t("project_cycles.active_cycle.leading")}
+
+
+ {estimateTypeFormatter(trailingValue)}
+
+
+ >
+ )}
+
+ {/* Date button */}
+ {cycleDetails.start_date && cycleDetails.end_date && (
+
} disabled>
+
+
+ )}
+
+ {/* Lead avatar */}
+ {createdByDetails &&
}
+
+ {/* Quick actions menu */}
+
+
+
+
+ }
+ quickActionElement={
+
+
+
+ }
+ />
+ );
+});
diff --git a/apps/web/core/components/cycles/active-cycles/v2/progress-donut.tsx b/apps/web/core/components/cycles/active-cycles/v2/progress-donut.tsx
index 465838d308..bb2162c5e9 100644
--- a/apps/web/core/components/cycles/active-cycles/v2/progress-donut.tsx
+++ b/apps/web/core/components/cycles/active-cycles/v2/progress-donut.tsx
@@ -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 | null | undefined;
- days_left: number;
};
function ProgressDonut(props: Props) {
- const { progress, days_left } = props;
- const [isHovering, setIsHovering] = useState(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 (
- setIsHovering(true)}
- onMouseLeave={() => setIsHovering(false)}
- >
-
-
- {progress ? (isHovering ? days_left : `${percentage ? percentage.toFixed(0) : 0}%`) : "0%"}
+
+
+
+ {`${percentage ? percentage.toFixed(0) : 0}%`}
-
- {isHovering && (
-
- {days_left === 1 ? "Day" : "Days"}
left
-
- )}
);
diff --git a/apps/web/core/components/cycles/active-cycles/v2/progress-header.tsx b/apps/web/core/components/cycles/active-cycles/v2/progress-header.tsx
index f3e103306f..6610af088f 100644
--- a/apps/web/core/components/cycles/active-cycles/v2/progress-header.tsx
+++ b/apps/web/core/components/cycles/active-cycles/v2/progress-header.tsx
@@ -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) => {
+ 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 (
router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`)}
>
-
-
- {progress === null &&
}
- {progress && (
-
- )}
-
+
+
+ {progress === null &&
}
+ {progress &&
}
+
-
Currently active cycle
+
-
- {cycleDetails.name}
-
+ {cycleDetails.name}
-
-
+
+ {cycleDetails.start_date && cycleDetails.end_date && (
+ } disabled>
+
+
+ )}
+ {createdByDetails && }
+
diff --git a/apps/web/core/components/cycles/active-cycles/v2/root.tsx b/apps/web/core/components/cycles/active-cycles/v2/root.tsx
index 154286cf0c..13b50b9432 100644
--- a/apps/web/core/components/cycles/active-cycles/v2/root.tsx
+++ b/apps/web/core/components/cycles/active-cycles/v2/root.tsx
@@ -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 (
+
+
+
+ {onCollapse && (
+
+ )}
+
+
+
+
+
+
+
+ );
+});
+
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
>(() => 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 (
);
+ }
+ // Collapsible "Active N" header with individually expandable/collapsible cycle entries
return (
- <>
-
- >
+
+
+
+
+
+ {activeCycleIds.map((id) =>
+ expandedCycleIds.has(id) ? (
+ handleToggleCycle(id)}
+ />
+ ) : (
+ handleToggleCycle(id)}
+ />
+ )
+ )}
+
+
);
});
diff --git a/apps/web/core/components/cycles/active-cycles/v2/scope-delta.tsx b/apps/web/core/components/cycles/active-cycles/v2/scope-delta.tsx
index d4112803e2..bea1c91a48 100644
--- a/apps/web/core/components/cycles/active-cycles/v2/scope-delta.tsx
+++ b/apps/web/core/components/cycles/active-cycles/v2/scope-delta.tsx
@@ -32,11 +32,11 @@ function ScopeDelta(props: Props) {
{prevData.scope < dataToday.scope! ? (
<>
-
+
+
+
>
) : (
<>
-
-
+
-
>
)}
{Math.round(delta)}%
diff --git a/apps/web/core/components/cycles/cycles-view.tsx b/apps/web/core/components/cycles/cycles-view.tsx
index de42b958f1..053f19b47f 100644
--- a/apps/web/core/components/cycles/cycles-view.tsx
+++ b/apps/web/core/components/cycles/cycles-view.tsx
@@ -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
;
+ // 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
;
if (filteredCycleIds.length === 0 && filteredCompletedCycleIds?.length === 0)
return (
@@ -66,6 +73,7 @@ export const CyclesView = observer(function CyclesView(props: ICyclesView) {
return (
;
- isActive?: boolean;
-};
-
-const defaultValues: Partial = {
- 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(
- 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) => {
- 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) => {
- 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) => {
- 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 (
- <>
- setTransferIssuesModal(false)}
- isOpen={transferIssuesModal}
- cycleId={cycleId.toString()}
- />
-
- {showIssueCount && (
-
-
- {cycleDetails.total_issues}
-
- )}
-
- {showTransferIssues && (
- {
- setTransferIssuesModal(true);
- }}
- >
-
- {t("project_cycles.transfer_work_items", { count: transferableIssuesCount })}
-
- )}
- {isActive ? (
- <>
-
- {/* Duration */}
-
- {renderFormattedDateInUserTimezone(cycleDetails.start_date ?? "")}
-
- {renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
-
- }
- disabled={!isProjectTimeZoneDifferent()}
- tooltipHeading={t("project_cycles.in_your_timezone")}
- >
-
-
-
-
-
- {projectUTCOffset && (
-
- {projectUTCOffset}
-
- )}
- {/* created by */}
- {createdByDetails &&
}
-
- >
- ) : (
- cycleDetails.start_date && (
- <>
- 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={
-
- {renderFormattedDateInUserTimezone(cycleDetails.start_date ?? "")}
-
- {renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
-
- }
- mergeDates
- required={cycleDetails.status !== "draft"}
- disabled
- hideIcon={{
- from: false,
- to: false,
- }}
- />
- >
- )
- )}
- {/* created by */}
- {createdByDetails && !isActive && }
- {!isActive && (
-
-
- {cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
-
- {cycleDetails.assignee_ids?.map((assignee_id) => {
- const member = getUserDetails(assignee_id);
- return (
-
- );
- })}
-
- ) : (
-
- )}
-
-
- )}
- {isEditingAllowed && !cycleDetails.archived_at && (
- {
- e.preventDefault();
- e.stopPropagation();
- if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
- else handleAddToFavorites(e);
- }}
- selected={!!cycleDetails.is_favorite}
- />
- )}
-
-
-
- >
- );
-});
diff --git a/apps/web/core/components/cycles/list/cycles-list-item.tsx b/apps/web/core/components/cycles/list/cycles-list-item.tsx
index 4117c178e1..cc3dc29856 100644
--- a/apps/web/core/components/cycles/list/cycles-list-item.tsx
+++ b/apps/web/core/components/cycles/list/cycles-list-item.tsx
@@ -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(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) => {
- e.preventDefault();
+ const openCycleOverview = (e: React.MouseEvent) => {
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) => {
+ const handleArchivedCycleClick = (e: React.MouseEvent) => {
openCycleOverview(e);
};
const handleItemClick = cycleDetails.archived_at ? handleArchivedCycleClick : undefined;
-
- const progress = calculateCycleProgress(cycleDetails);
-
return (
- {progress === 100 ? (
-
- ) : (
- {`${progress}%`}
- )}
-
+ prependTitleElement={}
+ appendTitleElement={
+ <>
+
+ >
}
actionableItems={
-
+ <>
+
+
+ {cycleDetails.start_date && cycleDetails.end_date && (
+ } disabled>
+
+
+ )}
+ {createdByDetails && }
+
+
+
+ >
}
quickActionElement={
@@ -123,7 +126,7 @@ export const CyclesListItem = observer(function CyclesListItem(props: TCyclesLis
}
isMobile={isMobile}
parentRef={parentRef}
- isSidebarOpen={searchParams.has("peekCycle")}
+ className={className}
/>
);
});
diff --git a/apps/web/core/components/cycles/list/root.tsx b/apps/web/core/components/cycles/list/root.tsx
index e17bb52888..3ec074b9db 100644
--- a/apps/web/core/components/cycles/list/root.tsx
+++ b/apps/web/core/components/cycles/list/root.tsx
@@ -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 (
-
+
void;
}) {
- const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
return (
-
+
@@ -105,18 +123,24 @@ export const CyclesList = observer(function CyclesList(props: ICyclesList) {
>
) : (
<>
-
+ {activeCycleIds && activeCycleIds.length > 0 && (
+
+ )}
{upcomingCycleIds && (
)}
>
)}
diff --git a/apps/web/core/components/cycles/quick-actions.tsx b/apps/web/core/components/cycles/quick-actions.tsx
index c84bb20d01..536e0d4cca 100644
--- a/apps/web/core/components/cycles/quick-actions.tsx
+++ b/apps/web/core/components/cycles/quick-actions.tsx
@@ -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
)}
}
+ customButton={}
placement="bottom-end"
+ portalElement={document.body}
closeOnSelect
maxHeight="lg"
buttonClassName={customClassName}
diff --git a/apps/web/core/components/cycles/settings/index.ts b/apps/web/core/components/cycles/settings/index.ts
index a5007eaeb6..00856e83e4 100644
--- a/apps/web/core/components/cycles/settings/index.ts
+++ b/apps/web/core/components/cycles/settings/index.ts
@@ -12,3 +12,4 @@
*/
export * from "./auto-schedule-cycles";
+export * from "./parallel-cycles";
diff --git a/apps/web/core/components/cycles/settings/parallel-cycles.tsx b/apps/web/core/components/cycles/settings/parallel-cycles.tsx
new file mode 100644
index 0000000000..087e46d986
--- /dev/null
+++ b/apps/web/core/components/cycles/settings/parallel-cycles.tsx
@@ -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 (
+
+
+ ) : (
+ }
+ onClick={() => togglePaidPlanModal(true)}
+ >
+ Upgrade
+
+ )
+ }
+ />
+
+ );
+});
diff --git a/apps/web/core/components/cycles/start-cycle/modal.tsx b/apps/web/core/components/cycles/start-cycle/modal.tsx
index 12836a0734..872a9ba94d 100644
--- a/apps/web/core/components/cycles/start-cycle/modal.tsx
+++ b/apps/web/core/components/cycles/start-cycle/modal.tsx
@@ -27,7 +27,6 @@ export function StartCycleModal(props: Props) {
Sure you want to start this cycle?
- {/*
*/}