-
-
- Ideal
-
-
-
-
Current
+
+ {cycle.total_issues > 0 ? (
+ <>
+
+
+
+
+
+ Ideal
+
+
+
+ Current
+
+ {plotType === "points" ? (
+
{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}
+ ) : (
+
{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}
+ )}
+
+
+
+ {completionChartDistributionData && (
+
+ {plotType === "points" ? (
+
+ ) : (
+
+ )}
+
+ )}
-
{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}
-
- >
- ) : (
- <>
-
-
-
- >
- )}
-
+ >
+ )}
+
+
);
};
diff --git a/web/core/components/cycles/analytics-sidebar/index.ts b/web/core/components/cycles/analytics-sidebar/index.ts
new file mode 100644
index 000000000..c509152a2
--- /dev/null
+++ b/web/core/components/cycles/analytics-sidebar/index.ts
@@ -0,0 +1,3 @@
+export * from "./root";
+export * from "./issue-progress";
+export * from "./progress-stats";
diff --git a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx
new file mode 100644
index 000000000..3c20c93dd
--- /dev/null
+++ b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx
@@ -0,0 +1,276 @@
+"use client";
+
+import { FC, Fragment, useCallback, useMemo, useState } from "react";
+import isEmpty from "lodash/isEmpty";
+import isEqual from "lodash/isEqual";
+import { observer } from "mobx-react";
+import { useSearchParams } from "next/navigation";
+import { AlertCircle, ChevronUp, ChevronDown } from "lucide-react";
+import { Disclosure, Transition } from "@headlessui/react";
+import { ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types";
+import { CustomSelect, Spinner } from "@plane/ui";
+// components
+import ProgressChart from "@/components/core/sidebar/progress-chart";
+import { CycleProgressStats } from "@/components/cycles";
+// constants
+import { EEstimateSystem } from "@/constants/estimates";
+import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
+// helpers
+import { getDate } from "@/helpers/date-time.helper";
+// hooks
+import { useIssues, useCycle, useProjectEstimates } from "@/hooks/store";
+
+type TCycleAnalyticsProgress = {
+ workspaceSlug: string;
+ projectId: string;
+ cycleId: string;
+};
+
+const cycleBurnDownChartOptions = [
+ { value: "burndown", label: "Issues" },
+ { value: "points", label: "Points" },
+];
+
+const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => {
+ if (!cycleDetails || cycleDetails === null) return cycleDetails;
+
+ const updatedCycleDetails: any = { ...cycleDetails };
+ if (!isEmpty(cycleDetails.progress_snapshot)) {
+ Object.keys(cycleDetails.progress_snapshot || {}).forEach((key) => {
+ const currentKey = key as keyof TProgressSnapshot;
+ if (!isEmpty(cycleDetails.progress_snapshot) && !isEmpty(updatedCycleDetails)) {
+ updatedCycleDetails[currentKey as keyof ICycle] = cycleDetails?.progress_snapshot?.[currentKey];
+ }
+ });
+ }
+ return updatedCycleDetails;
+};
+
+export const CycleAnalyticsProgress: FC
= observer((props) => {
+ // props
+ const { workspaceSlug, projectId, cycleId } = props;
+ // router
+ const searchParams = useSearchParams();
+ const peekCycle = searchParams.get("peekCycle") || undefined;
+ // hooks
+ const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
+ const { getPlotTypeByCycleId, setPlotType, getCycleById, fetchCycleDetails } = useCycle();
+ const {
+ issuesFilter: { issueFilters, updateFilters },
+ } = useIssues(EIssuesStoreType.CYCLE);
+ // state
+ const [loader, setLoader] = useState(false);
+
+ // derived values
+ const cycleDetails = validateCycleSnapshot(getCycleById(cycleId));
+ const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId);
+ const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
+ const estimateDetails =
+ isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
+ const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS;
+
+ const completedIssues = cycleDetails?.completed_issues || 0;
+ const totalIssues = cycleDetails?.total_issues || 0;
+ const completedEstimatePoints = cycleDetails?.completed_estimate_points || 0;
+ const totalEstimatePoints = cycleDetails?.total_estimate_points || 0;
+
+ const progressHeaderPercentage = cycleDetails
+ ? plotType === "points"
+ ? completedEstimatePoints != 0 && totalEstimatePoints != 0
+ ? Math.round((completedEstimatePoints / totalEstimatePoints) * 100)
+ : 0
+ : completedIssues != 0 && completedIssues != 0
+ ? Math.round((completedIssues / totalIssues) * 100)
+ : 0
+ : 0;
+
+ const chartDistributionData =
+ plotType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined;
+ const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
+
+ const groupedIssues = useMemo(
+ () => ({
+ backlog: plotType === "points" ? cycleDetails?.backlog_estimate_points || 0 : cycleDetails?.backlog_issues || 0,
+ unstarted:
+ plotType === "points" ? cycleDetails?.unstarted_estimate_points || 0 : cycleDetails?.unstarted_issues || 0,
+ started: plotType === "points" ? cycleDetails?.started_estimate_points || 0 : cycleDetails?.started_issues || 0,
+ completed:
+ plotType === "points" ? cycleDetails?.completed_estimate_points || 0 : cycleDetails?.completed_issues || 0,
+ cancelled:
+ plotType === "points" ? cycleDetails?.cancelled_estimate_points || 0 : cycleDetails?.cancelled_issues || 0,
+ }),
+ [plotType, cycleDetails]
+ );
+
+ const cycleStartDate = getDate(cycleDetails?.start_date);
+ const cycleEndDate = getDate(cycleDetails?.end_date);
+ const isCycleStartDateValid = cycleStartDate && cycleStartDate <= new Date();
+ const isCycleEndDateValid = cycleStartDate && cycleEndDate && cycleEndDate >= cycleStartDate;
+ const isCycleDateValid = isCycleStartDateValid && isCycleEndDateValid;
+
+ // handlers
+ const onChange = async (value: TCyclePlotType) => {
+ setPlotType(cycleId, value);
+ if (!workspaceSlug || !projectId || !cycleId) return;
+ try {
+ setLoader(true);
+ await fetchCycleDetails(workspaceSlug, projectId, cycleId);
+ setLoader(false);
+ } catch (error) {
+ setLoader(false);
+ setPlotType(cycleId, plotType);
+ }
+ };
+
+ const handleFiltersUpdate = useCallback(
+ (key: keyof IIssueFilterOptions, value: string | string[]) => {
+ if (!workspaceSlug || !projectId) return;
+
+ let newValues = issueFilters?.filters?.[key] ?? [];
+
+ if (Array.isArray(value)) {
+ if (key === "state") {
+ if (isEqual(newValues, value)) newValues = [];
+ else newValues = value;
+ } else {
+ value.forEach((val) => {
+ if (!newValues.includes(val)) newValues.push(val);
+ else newValues.splice(newValues.indexOf(val), 1);
+ });
+ }
+ } else {
+ if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
+ else newValues.push(value);
+ }
+ updateFilters(
+ workspaceSlug.toString(),
+ projectId.toString(),
+ EIssueFilterType.FILTERS,
+ { [key]: newValues },
+ cycleId
+ );
+ },
+ [workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
+ );
+
+ if (!cycleDetails) return <>>;
+ return (
+
+
+ {({ open }) => (
+
+ {/* progress bar header */}
+ {isCycleDateValid ? (
+
+
+ Progress
+ {progressHeaderPercentage > 0 && (
+ {`${progressHeaderPercentage}%`}
+ )}
+
+ {isCurrentEstimateTypeIsPoints && (
+ <>
+
+ {cycleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"}
+ }
+ onChange={onChange}
+ maxHeight="lg"
+ >
+ {cycleBurnDownChartOptions.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+ {loader &&
}
+ >
+ )}
+
+ {open ? (
+
+ ) : (
+
+ )}
+
+
+ ) : (
+
+
Progress
+
+
+
+ {cycleDetails?.start_date && cycleDetails?.end_date
+ ? "This cycle isn't active yet."
+ : "Invalid date. Please enter valid date."}
+
+
+
+ )}
+
+
+
+ {/* progress burndown chart */}
+
+
+
+
+ Ideal
+
+
+
+ Current
+
+
+ {cycleStartDate && cycleEndDate && completionChartDistributionData && (
+
+ {plotType === "points" ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ {/* progress detailed view */}
+ {chartDistributionData && (
+
+
+
+ )}
+
+
+
+ )}
+
+
+ );
+});
diff --git a/web/core/components/cycles/analytics-sidebar/progress-stats.tsx b/web/core/components/cycles/analytics-sidebar/progress-stats.tsx
new file mode 100644
index 000000000..dc0efb255
--- /dev/null
+++ b/web/core/components/cycles/analytics-sidebar/progress-stats.tsx
@@ -0,0 +1,372 @@
+"use client";
+
+import { FC } from "react";
+import { observer } from "mobx-react";
+import Image from "next/image";
+import { Tab } from "@headlessui/react";
+import {
+ IIssueFilterOptions,
+ IIssueFilters,
+ TCycleDistribution,
+ TCycleEstimateDistribution,
+ TCyclePlotType,
+ TStateGroups,
+} from "@plane/types";
+import { Avatar, StateGroupIcon } from "@plane/ui";
+// components
+import { SingleProgressStats } from "@/components/core";
+// helpers
+import { cn } from "@/helpers/common.helper";
+// hooks
+import { useProjectState } from "@/hooks/store";
+import useLocalStorage from "@/hooks/use-local-storage";
+// public
+import emptyLabel from "@/public/empty-state/empty_label.svg";
+import emptyMembers from "@/public/empty-state/empty_members.svg";
+
+// assignee types
+type TAssigneeData = {
+ id: string | undefined;
+ title: string | undefined;
+ avatar: string | undefined;
+ completed: number;
+ total: number;
+}[];
+
+type TAssigneeStatComponent = {
+ distribution: TAssigneeData;
+ isEditable?: boolean;
+ filters?: IIssueFilters | undefined;
+ handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
+};
+
+// labelTypes
+type TLabelData = {
+ id: string | undefined;
+ title: string | undefined;
+ color: string | undefined;
+ completed: number;
+ total: number;
+}[];
+
+type TLabelStatComponent = {
+ distribution: TLabelData;
+ isEditable?: boolean;
+ filters?: IIssueFilters | undefined;
+ handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
+};
+
+// stateTypes
+type TStateData = {
+ state: string | undefined;
+ completed: number;
+ total: number;
+}[];
+
+type TStateStatComponent = {
+ distribution: TStateData;
+ totalIssuesCount: number;
+ isEditable?: boolean;
+ handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
+};
+
+export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => {
+ const { distribution, isEditable, filters, handleFiltersUpdate } = props;
+ return (
+
+ {distribution && distribution.length > 0 ? (
+ distribution.map((assignee, index) => {
+ if (assignee?.id)
+ return (
+
+
+ {assignee?.title ?? ""}
+
+ }
+ completed={assignee?.completed ?? 0}
+ total={assignee?.total ?? 0}
+ {...(isEditable && {
+ onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""),
+ selected: filters?.filters?.assignees?.includes(assignee.id ?? ""),
+ })}
+ />
+ );
+ else
+ return (
+
+
+
+
+ No assignee
+
+ }
+ completed={assignee?.completed ?? 0}
+ total={assignee?.total ?? 0}
+ />
+ );
+ })
+ ) : (
+