"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 && (
)}
)}
); });