diff --git a/web/core/components/core/sidebar/progress-chart.tsx b/web/core/components/core/sidebar/progress-chart.tsx index f62736002..25dd3fee5 100644 --- a/web/core/components/core/sidebar/progress-chart.tsx +++ b/web/core/components/core/sidebar/progress-chart.tsx @@ -136,7 +136,7 @@ const ProgressChart: React.FC = ({ enableSlices="x" sliceTooltip={(datum) => (
- {datum.slice.points[0].data.yFormatted} + {datum.slice.points?.[1]?.data?.yFormatted ?? datum.slice.points[0].data.yFormatted} {plotTitle} pending on {datum.slice.points[0].data.xFormatted}
diff --git a/web/core/constants/state.ts b/web/core/constants/state.ts index 3b3dbcaf8..b35951046 100644 --- a/web/core/constants/state.ts +++ b/web/core/constants/state.ts @@ -40,3 +40,10 @@ export const STATE_GROUPS: { }; export const ARCHIVABLE_STATE_GROUPS = [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key]; +export const COMPLETED_STATE_GROUPS = [STATE_GROUPS.completed.key]; +export const PENDING_STATE_GROUPS = [ + STATE_GROUPS.backlog.key, + STATE_GROUPS.unstarted.key, + STATE_GROUPS.started.key, + STATE_GROUPS.cancelled.key, +]; diff --git a/web/core/store/cycle.store.ts b/web/core/store/cycle.store.ts index 736881e6d..9078c0444 100644 --- a/web/core/store/cycle.store.ts +++ b/web/core/store/cycle.store.ts @@ -8,6 +8,7 @@ import { ICycle, CycleDateCheckData, TCyclePlotType } from "@plane/types"; // helpers import { orderCycles, shouldFilterCycle } from "@/helpers/cycle.helper"; import { getDate } from "@/helpers/date-time.helper"; +import { DistributionUpdates, updateDistribution } from "@/helpers/distribution-update.helper"; // services import { CycleService } from "@/services/cycle.service"; import { CycleArchiveService } from "@/services/cycle_archive.service"; @@ -44,6 +45,7 @@ export interface ICycleStore { getProjectCycleIds: (projectId: string) => string[] | null; getPlotTypeByCycleId: (cycleId: string) => TCyclePlotType; // actions + updateCycleDistribution: (distributionUpdates: DistributionUpdates, cycleId: string) => void; validateDate: (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => Promise; setPlotType: (cycleId: string, plotType: TCyclePlotType) => void; // fetch @@ -485,6 +487,22 @@ export class CycleStore implements ICycleStore { return response; }); + /** + * This method updates the cycle's stats locally without fetching the updated stats from backend + * @param distributionUpdates + * @param cycleId + * @returns + */ + updateCycleDistribution = (distributionUpdates: DistributionUpdates, cycleId: string) => { + const cycle = this.cycleMap[cycleId]; + + if (!cycle) return; + + runInAction(() => { + updateDistribution(cycle, distributionUpdates); + }); + }; + /** * @description creates a new cycle * @param workspaceSlug diff --git a/web/core/store/estimates/project-estimate.store.ts b/web/core/store/estimates/project-estimate.store.ts index 5c0607e2f..9af5794f1 100644 --- a/web/core/store/estimates/project-estimate.store.ts +++ b/web/core/store/estimates/project-estimate.store.ts @@ -26,6 +26,7 @@ export interface IProjectEstimateStore { error: TErrorCodes | undefined; // computed currentActiveEstimateId: string | undefined; + currentActiveEstimate: IEstimate | undefined; archivedEstimateIds: string[] | undefined; areEstimateEnabledByProjectId: (projectId: string) => boolean; estimateIdsByProjectId: (projectId: string) => string[] | undefined; @@ -61,6 +62,7 @@ export class ProjectEstimateStore implements IProjectEstimateStore { error: observable, // computed currentActiveEstimateId: computed, + currentActiveEstimate: computed, archivedEstimateIds: computed, // actions getWorkspaceEstimates: action, @@ -85,6 +87,20 @@ export class ProjectEstimateStore implements IProjectEstimateStore { return currentActiveEstimateId?.id ?? undefined; } + // computed + /** + * @description get current active estimate for a project + * @returns { string | undefined } + */ + get currentActiveEstimate(): IEstimate | undefined { + const { projectId } = this.store.router; + if (!projectId) return undefined; + const currentActiveEstimate = Object.values(this.estimates || {}).find( + (p) => p.project === projectId && p.last_used + ); + return currentActiveEstimate ?? undefined; + } + /** * @description get all archived estimate ids for a project * @returns { string[] | undefined } diff --git a/web/core/store/issue/archived/issue.store.ts b/web/core/store/issue/archived/issue.store.ts index 293ce7a3a..c3091d5c5 100644 --- a/web/core/store/issue/archived/issue.store.ts +++ b/web/core/store/issue/archived/issue.store.ts @@ -73,6 +73,9 @@ export class ArchivedIssues extends BaseIssuesStore implements IArchivedIssues { projectId && this.rootIssueStore.rootStore.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId); }; + /** */ + updateParentStats = () => {}; + /** * This method is called to fetch the first issues of pagination * @param workspaceSlug diff --git a/web/core/store/issue/cycle/issue.store.ts b/web/core/store/issue/cycle/issue.store.ts index 41dacd8a0..7f6ac53ec 100644 --- a/web/core/store/issue/cycle/issue.store.ts +++ b/web/core/store/issue/cycle/issue.store.ts @@ -18,7 +18,10 @@ import { ViewFlags, TBulkOperationsPayload, } from "@plane/types"; +// helpers +import { getDistributionPathsPostUpdate } from "@/helpers/distribution-update.helper"; import { BaseIssuesStore, IBaseIssuesStore } from "../helpers/base-issues.store"; +// import { IIssueRootStore } from "../root.store"; import { ICycleIssuesFilter } from "./filter.store"; @@ -134,9 +137,23 @@ export class CycleIssues extends BaseIssuesStore implements ICycleIssues { */ fetchParentStats = (workspaceSlug: string, projectId?: string | undefined, id?: string | undefined) => { const cycleId = id ?? this.cycleId; + projectId && cycleId && this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); }; + updateParentStats = (prevIssueState?: TIssue, nextIssueState?: TIssue, id?: string | undefined) => { + const distributionUpdates = getDistributionPathsPostUpdate( + prevIssueState, + nextIssueState, + this.rootIssueStore.rootStore.state.stateMap, + this.rootIssueStore.rootStore.projectEstimate?.currentActiveEstimate?.estimatePointById + ); + + const cycleId = id ?? this.cycleId; + + cycleId && this.rootIssueStore.rootStore.cycle.updateCycleDistribution(distributionUpdates, cycleId); + }; + /** * This method is called to fetch the first issues of pagination * @param workspaceSlug diff --git a/web/core/store/issue/draft/issue.store.ts b/web/core/store/issue/draft/issue.store.ts index ca322ac8d..ac1a424d7 100644 --- a/web/core/store/issue/draft/issue.store.ts +++ b/web/core/store/issue/draft/issue.store.ts @@ -70,6 +70,9 @@ export class DraftIssues extends BaseIssuesStore implements IDraftIssues { projectId && this.rootIssueStore.rootStore.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId); }; + /** */ + updateParentStats = () => {}; + /** * This method is called to fetch the first issues of pagination * @param workspaceSlug diff --git a/web/core/store/issue/helpers/base-issues.store.ts b/web/core/store/issue/helpers/base-issues.store.ts index a50d1585f..5ba71080f 100644 --- a/web/core/store/issue/helpers/base-issues.store.ts +++ b/web/core/store/issue/helpers/base-issues.store.ts @@ -257,6 +257,8 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { // Abstract class to be implemented to fetch parent stats such as project, module or cycle details abstract fetchParentStats: (workspaceSlug: string, projectId?: string, id?: string) => void; + abstract updateParentStats: (prevIssueState?: TIssue, nextIssueState?: TIssue, id?: string) => void; + // current Module Id from url get moduleId() { return this.rootIssueStore.moduleId; @@ -523,7 +525,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { this.addIssue(response, shouldUpdateList); // If shouldUpdateList is true, call fetchParentStats - shouldUpdateList && this.fetchParentStats(workspaceSlug, projectId); + shouldUpdateList && (await this.fetchParentStats(workspaceSlug, projectId)); return response; } catch (error) { @@ -557,6 +559,12 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { // Check if should Sync if (!shouldSync) return; + // update parent stats optimistically + this.updateParentStats(issueBeforeUpdate, { + ...issueBeforeUpdate, + ...data, + } as TIssue); + // call API to update the issue await this.issueService.patchIssue(workspaceSlug, projectId, issueId, data); @@ -633,6 +641,12 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { */ async removeIssue(workspaceSlug: string, projectId: string, issueId: string) { try { + // Store Before state of the issue + const issueBeforeRemoval = clone(this.rootIssueStore.issues.getIssueById(issueId)); + + // update parent stats optimistically + this.updateParentStats(issueBeforeRemoval, undefined); + // Male API call await this.issueService.deleteIssue(workspaceSlug, projectId, issueId); @@ -659,6 +673,11 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { */ async issueArchive(workspaceSlug: string, projectId: string, issueId: string) { try { + const issueBeforeArchive = clone(this.rootIssueStore.issues.getIssueById(issueId)); + + // update parent stats optimistically + this.updateParentStats(issueBeforeArchive, undefined); + // Male API call const response = await this.issueArchiveService.archiveIssue(workspaceSlug, projectId, issueId); @@ -872,11 +891,16 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { */ async removeIssueFromCycle(workspaceSlug: string, projectId: string, cycleId: string, issueId: string) { try { + const issueBeforeRemoval = clone(this.rootIssueStore.issues.getIssueById(issueId)); + + // update parent stats optimistically + if (this.cycleId === cycleId) this.updateParentStats(issueBeforeRemoval, undefined, cycleId); + // Perform an APi call to remove issue from cycle await this.issueService.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); // if cycle Id is the current Cycle Id then call fetch parent stats - if (this.cycleId === cycleId) this.fetchParentStats(workspaceSlug, projectId); + if (this.cycleId === cycleId) this.fetchParentStats(workspaceSlug, projectId, cycleId); runInAction(() => { // If cycle Id is the current cycle Id, then, remove issue from list of issueIds @@ -890,11 +914,21 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { } } + /** + * Adds cycle to issue optimistically + * @param workspaceSlug + * @param projectId + * @param cycleId + * @param issueId + * @returns + */ addCycleToIssue = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => { const issueCycleId = this.rootIssueStore.issues.getIssueById(issueId)?.cycle_id; if (issueCycleId === cycleId) return; + const issueBeforeUpdate = clone(this.rootIssueStore.issues.getIssueById(issueId)); + try { // Update issueIds from current store runInAction(() => { @@ -906,12 +940,19 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { this.issueUpdate(workspaceSlug, projectId, issueId, { cycle_id: cycleId }, false); }); + const issueAfterUpdate = clone(this.rootIssueStore.issues.getIssueById(issueId)); + + // update parent stats optimistically + if (this.cycleId === cycleId || this.cycleId === issueCycleId) + this.updateParentStats(issueBeforeUpdate, issueAfterUpdate, this.cycleId); + await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, { issues: [issueId], }); // if cycle Id is the current Cycle Id then call fetch parent stats - if (this.cycleId === cycleId) this.fetchParentStats(workspaceSlug, projectId); + if (this.cycleId === cycleId || this.cycleId === issueCycleId) + this.fetchParentStats(workspaceSlug, projectId, this.cycleId); } catch (error) { // remove the new issue ids from the cycle issues map runInAction(() => { @@ -933,6 +974,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { * @returns */ removeCycleFromIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { + const issueBeforeRemoval = clone(this.rootIssueStore.issues.getIssueById(issueId)); const issueCycleId = this.rootIssueStore.issues.getIssueById(issueId)?.cycle_id; if (!issueCycleId) return; try { @@ -945,10 +987,14 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { this.issueUpdate(workspaceSlug, projectId, issueId, { cycle_id: null }, false); }); + // update parent stats optimistically + if (this.cycleId === issueCycleId) this.updateParentStats(issueBeforeRemoval, undefined, issueCycleId); + // make API call await this.issueService.removeIssueFromCycle(workspaceSlug, projectId, issueCycleId, issueId); + // if cycle Id is the current Cycle Id then call fetch parent stats - if (this.cycleId === issueCycleId) this.fetchParentStats(workspaceSlug, projectId); + if (this.cycleId === issueCycleId) this.fetchParentStats(workspaceSlug, projectId, issueCycleId); } catch (error) { // revert back changes if fails // Update issueIds from current store @@ -1077,7 +1123,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { }); if (moduleIds.includes(this.moduleId ?? "")) { - this.fetchParentStats(workspaceSlug, projectId); + this.fetchParentStats(workspaceSlug, projectId, this.moduleId); } } catch (error) { throw error; @@ -1100,6 +1146,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { removeModuleIds: string[] ) { // keep a copy of the original module ids + const issueBeforeChanges = clone(this.rootIssueStore.issues.getIssueById(issueId)); const originalModuleIds = get(this.rootIssueStore.issues.issuesMap, [issueId, "module_ids"]) ?? []; try { runInAction(() => { @@ -1120,6 +1167,13 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { this.issueUpdate(workspaceSlug, projectId, issueId, { module_ids: currentModuleIds }, false); }); + const issueAfterChanges = clone(this.rootIssueStore.issues.getIssueById(issueId)); + + // update parent stats optimistically + if (addModuleIds.includes(this.moduleId || "") || removeModuleIds.includes(this.moduleId || "")) { + this.updateParentStats(issueBeforeChanges, issueAfterChanges, this.moduleId); + } + //Perform API call await this.moduleService.addModulesToIssue(workspaceSlug, projectId, issueId, { modules: addModuleIds, diff --git a/web/core/store/issue/module/issue.store.ts b/web/core/store/issue/module/issue.store.ts index ee78b3d7c..266c9d661 100644 --- a/web/core/store/issue/module/issue.store.ts +++ b/web/core/store/issue/module/issue.store.ts @@ -8,10 +8,10 @@ import { TIssuesResponse, TBulkOperationsPayload, } from "@plane/types"; +// helpers +import { getDistributionPathsPostUpdate } from "@/helpers/distribution-update.helper"; import { BaseIssuesStore, IBaseIssuesStore } from "../helpers/base-issues.store"; -// services -// types -// store +// import { IIssueRootStore } from "../root.store"; import { IModuleIssuesFilter } from "./filter.store"; @@ -91,6 +91,26 @@ export class ModuleIssues extends BaseIssuesStore implements IModuleIssues { this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); }; + /** + * Update Parent stats before fetching from server + * @param prevIssueState + * @param nextIssueState + * @param id + */ + updateParentStats = (prevIssueState?: TIssue, nextIssueState?: TIssue, id?: string | undefined) => { + // get distribution updates + const distributionUpdates = getDistributionPathsPostUpdate( + prevIssueState, + nextIssueState, + this.rootIssueStore.rootStore.state.stateMap, + this.rootIssueStore.rootStore.projectEstimate?.currentActiveEstimate?.estimatePointById + ); + + const moduleId = id ?? this.moduleId; + + moduleId && this.rootIssueStore.rootStore.module.updateModuleDistribution(distributionUpdates, moduleId); + }; + /** * This method is called to fetch the first issues of pagination * @param workspaceSlug diff --git a/web/core/store/issue/profile/issue.store.ts b/web/core/store/issue/profile/issue.store.ts index 9637cb623..855dbcd86 100644 --- a/web/core/store/issue/profile/issue.store.ts +++ b/web/core/store/issue/profile/issue.store.ts @@ -91,6 +91,9 @@ export class ProfileIssues extends BaseIssuesStore implements IProfileIssues { fetchParentStats = () => {}; + /** */ + updateParentStats = () => {}; + /** * This method is called to fetch the first issues of pagination * @param workspaceSlug diff --git a/web/core/store/issue/project-views/issue.store.ts b/web/core/store/issue/project-views/issue.store.ts index 07824dcd2..f2e9ba02e 100644 --- a/web/core/store/issue/project-views/issue.store.ts +++ b/web/core/store/issue/project-views/issue.store.ts @@ -70,6 +70,9 @@ export class ProjectViewIssues extends BaseIssuesStore implements IProjectViewIs fetchParentStats = async () => {}; + /** */ + updateParentStats = () => {}; + /** * This method is called to fetch the first issues of pagination * @param workspaceSlug diff --git a/web/core/store/issue/project/issue.store.ts b/web/core/store/issue/project/issue.store.ts index 585f5b53a..66f152ac6 100644 --- a/web/core/store/issue/project/issue.store.ts +++ b/web/core/store/issue/project/issue.store.ts @@ -79,6 +79,9 @@ export class ProjectIssues extends BaseIssuesStore implements IProjectIssues { projectId && this.rootIssueStore.rootStore.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId); }; + /** */ + updateParentStats = () => {}; + /** * This method is called to fetch the first issues of pagination * @param workspaceSlug diff --git a/web/core/store/issue/workspace/issue.store.ts b/web/core/store/issue/workspace/issue.store.ts index daeacb650..3ac1fac1d 100644 --- a/web/core/store/issue/workspace/issue.store.ts +++ b/web/core/store/issue/workspace/issue.store.ts @@ -76,6 +76,9 @@ export class WorkspaceIssues extends BaseIssuesStore implements IWorkspaceIssues fetchParentStats = () => {}; + /** */ + updateParentStats = () => {}; + /** * This method is called to fetch the first issues of pagination * @param workspaceSlug diff --git a/web/core/store/module.store.ts b/web/core/store/module.store.ts index 2c0953419..a67b47946 100644 --- a/web/core/store/module.store.ts +++ b/web/core/store/module.store.ts @@ -7,6 +7,7 @@ import { computedFn } from "mobx-utils"; // types import { IModule, ILinkDetails, TModulePlotType } from "@plane/types"; // helpers +import { DistributionUpdates, updateDistribution } from "@/helpers/distribution-update.helper"; import { orderModules, shouldFilterModule } from "@/helpers/module.helper"; // services import { ModuleService } from "@/services/module.service"; @@ -35,6 +36,7 @@ export interface IModuleStore { // actions setPlotType: (moduleId: string, plotType: TModulePlotType) => void; // fetch + updateModuleDistribution: (distributionUpdates: DistributionUpdates, moduleId: string) => void; fetchWorkspaceModules: (workspaceSlug: string) => Promise; fetchModules: (workspaceSlug: string, projectId: string) => Promise; fetchArchivedModules: (workspaceSlug: string, projectId: string) => Promise; @@ -319,6 +321,22 @@ export class ModulesStore implements IModuleStore { return response; }); + /** + * This method updates the module's stats locally without fetching the updated stats from backend + * @param distributionUpdates + * @param moduleId + * @returns + */ + updateModuleDistribution = (distributionUpdates: DistributionUpdates, moduleId: string) => { + const moduleInfo = this.moduleMap[moduleId]; + + if (!moduleInfo) return; + + runInAction(() => { + updateDistribution(moduleInfo, distributionUpdates); + }); + }; + /** * @description fetch module details * @param workspaceSlug diff --git a/web/helpers/distribution-update.helper.ts b/web/helpers/distribution-update.helper.ts new file mode 100644 index 000000000..5fdd09a8c --- /dev/null +++ b/web/helpers/distribution-update.helper.ts @@ -0,0 +1,282 @@ +import { format } from "date-fns"; +import get from "lodash/get"; +import set from "lodash/set"; +// types +import { ICycle, IEstimatePoint, IModule, IState, TIssue } from "@plane/types"; +// constants +import { STATE_GROUPS, COMPLETED_STATE_GROUPS } from "@/constants/state"; +// helper +import { getDate } from "./date-time.helper"; + +export type DistributionObjectUpdate = { + id: string; + completed_issues?: number; + pending_issues?: number; + total_issues: number; + completed_estimates?: number; + pending_estimates?: number; + total_estimates: number; +}; + +type ChartUpdates = { + updates: { + path: string[]; + value: number; + }[]; + isCompleted?: boolean; +}; + +export type DistributionUpdates = { + pathUpdates: { path: string[]; value: number }[]; + assigneeUpdates: DistributionObjectUpdate[]; + labelUpdates: DistributionObjectUpdate[]; +}; + +const STATE_DISTRIBUTION = { + [STATE_GROUPS.backlog.key]: { + key: STATE_GROUPS.backlog.key, + issues: "backlog_issues", + points: "backlog_estimate_points", + }, + [STATE_GROUPS.unstarted.key]: { + key: STATE_GROUPS.unstarted.key, + issues: "unstarted_issues", + points: "unstarted_estimate_points", + }, + [STATE_GROUPS.started.key]: { + key: STATE_GROUPS.started.key, + issues: "started_issues", + points: "started_estimate_points", + }, + [STATE_GROUPS.completed.key]: { + key: STATE_GROUPS.completed.key, + issues: "completed_issues", + points: "completed_estimate_points", + }, + [STATE_GROUPS.cancelled.key]: { + key: STATE_GROUPS.cancelled.key, + issues: "cancelled_issues", + points: "cancelled_estimate_points", + }, +}; + +/** + * Get Distribution updates with the help of previous and next issue states + * @param prevIssueState + * @param nextIssueState + * @param stateMap + * @param estimatePointById + * @returns + */ +export const getDistributionPathsPostUpdate = ( + prevIssueState: TIssue | undefined, + nextIssueState: TIssue | undefined, + stateMap: Record, + estimatePointById?: (estimatePointId: string) => IEstimatePoint | undefined +): DistributionUpdates => { + const prevIssueDistribution = getDistributionDataOfIssue(prevIssueState, -1, stateMap, estimatePointById); + const nextIssueDistribution = getDistributionDataOfIssue(nextIssueState, 1, stateMap, estimatePointById); + + const prevChartDistribution = prevIssueDistribution.chartUpdates; + const nextChartDistribution = nextIssueDistribution.chartUpdates; + + let chartUpdates: { + path: string[]; + value: number; + }[]; + + // if the completed status of chart updates are same the get chart updates from both the issue states + if (prevChartDistribution.isCompleted === nextChartDistribution.isCompleted) { + chartUpdates = [...prevChartDistribution.updates, ...nextChartDistribution.updates]; + } // if not the get chart updates from only the next update + else { + chartUpdates = [...nextChartDistribution.updates]; + } + + // merge the updates from both issue states into a single object + return { + pathUpdates: [...prevIssueDistribution.pathUpdates, ...nextIssueDistribution.pathUpdates, ...chartUpdates], + assigneeUpdates: [...prevIssueDistribution.assigneeUpdates, ...nextIssueDistribution.assigneeUpdates], + labelUpdates: [...prevIssueDistribution.labelUpdates, ...nextIssueDistribution.labelUpdates], + }; +}; + +/** + * Get Distribution update for a single issue state + * @param issue + * @param multiplier + * @param stateMap + * @param estimatePointById + * @returns + */ +const getDistributionDataOfIssue = ( + issue: TIssue | undefined, + multiplier: -1 | 1, + stateMap: Record, + estimatePointById?: (estimatePointId: string) => IEstimatePoint | undefined +): DistributionUpdates & { chartUpdates: ChartUpdates } => { + const pathUpdates: { path: string[]; value: number }[] = []; + + // If issue does not exist, send a default object + if (!issue) return { pathUpdates, assigneeUpdates: [], labelUpdates: [], chartUpdates: { updates: [] } }; + + const state = stateMap[issue.state_id ?? ""]; + const stateGroup = state.group; + + // get if the state is in completed state + const isCompleted = COMPLETED_STATE_GROUPS.indexOf(stateGroup) > -1; + // get estimate point in number for the issue + const estimatePoint = parseFloat(estimatePointById?.(issue.estimate_point ?? "")?.value ?? "0"); + + // add all the path updates that can be updated directly on the distribution object + pathUpdates.push({ path: ["total_issues"], value: multiplier }); + pathUpdates.push({ path: ["total_estimate_points"], value: multiplier * estimatePoint }); + + // path updates for state distributions + const stateDistribution = STATE_DISTRIBUTION[stateGroup]; + + pathUpdates.push({ path: [stateDistribution.issues], value: multiplier }); + pathUpdates.push({ path: [stateDistribution.points], value: multiplier * estimatePoint }); + + // get assignee and label distribution updates + const assigneeUpdates = getObjectDistributionArray(issue.assignee_ids, isCompleted, estimatePoint, multiplier); + const labelUpdates = getObjectDistributionArray(issue.label_ids, isCompleted, estimatePoint, multiplier); + + // chart updates based on date of completed or not completed + const chartUpdates = getChartUpdates(isCompleted, issue.completed_at, estimatePoint, multiplier); + return { + pathUpdates, + assigneeUpdates, + labelUpdates, + chartUpdates, + }; +}; + +/** + * This is to get distribution update array for either assignees and labels object + * @param ids the assignee or label ids of issue + * @param isCompleted + * @param estimatePoint + * @param multiplier + * @returns + */ +const getObjectDistributionArray = (ids: string[], isCompleted: boolean, estimatePoint: number, multiplier: -1 | 1) => { + const objectDistributionArray: DistributionObjectUpdate[] = []; + + // iterate over each id + for (const id of ids) { + const objectDistribution: DistributionObjectUpdate = { + id, + total_issues: multiplier, + total_estimates: estimatePoint * multiplier, + }; + + // update paths for issue counts and estimate counts + if (isCompleted) { + objectDistribution["completed_issues"] = multiplier; + objectDistribution["completed_estimates"] = estimatePoint * multiplier; + } else { + objectDistribution["pending_issues"] = multiplier; + objectDistribution["pending_estimates"] = estimatePoint * multiplier; + } + + objectDistributionArray.push(objectDistribution); + } + + return objectDistributionArray; +}; + +/** + * get chart distribution based of completed or not completed states + * @param isCompleted + * @param completedAt + * @param estimatePoint + * @param multiplier + * @returns + */ +const getChartUpdates = ( + isCompleted: boolean, + completedAt: string | null, + estimatePoint: number, + multiplier: -1 | 1 +) => { + // if completed At date does not exist use current date + let dateToUpdate = format(new Date(), "yyyy-MM-dd"); + const completedAtDate = getDate(completedAt); + if (completedAt && completedAtDate) { + dateToUpdate = format(completedAtDate, "yyyy-MM-dd"); + } + + // multiplier based on isCompleted state, it determines if the current count is to be added or subtracted from the list + const completedAtMultiplier = isCompleted ? -1 : 1; + + return { + updates: [ + { path: ["distribution", "completion_chart", dateToUpdate], value: multiplier * completedAtMultiplier }, + { + path: ["estimate_distribution", "completion_chart", dateToUpdate], + value: multiplier * completedAtMultiplier * estimatePoint, + }, + ], + isCompleted, + }; +}; + +/** + * Method to update distribution of either cycle or module object + * @param distributionObject + * @param distributionUpdates + */ +export const updateDistribution = (distributionObject: ICycle | IModule, distributionUpdates: DistributionUpdates) => { + const { pathUpdates, assigneeUpdates, labelUpdates } = distributionUpdates; + + // iterate over path updates and directly apply changes on the distribution object + for (const update of pathUpdates) { + const { path, value } = update; + const currentValue: number = get(distributionObject, path); + if (currentValue !== undefined) set(distributionObject, path, (currentValue ?? 0) + value); + } + + // for assignee update iterate through the assignee update and apply at the respective position + for (const assigneeUpdate of assigneeUpdates) { + const { id } = assigneeUpdate; + + // find and update the assignee issue counts + const issuesAssignee = distributionObject.distribution?.assignees?.find((assignee) => assignee.assignee_id === id); + if (issuesAssignee) { + issuesAssignee.completed_issues += assigneeUpdate.completed_issues ?? 0; + issuesAssignee.pending_issues += assigneeUpdate.pending_issues ?? 0; + issuesAssignee.total_issues += assigneeUpdate.total_issues; + } + + // find and update the assignee points + const pointsAssignee = distributionObject.estimate_distribution?.assignees?.find( + (assignee) => assignee.assignee_id === id + ); + if (pointsAssignee) { + pointsAssignee.completed_estimates += assigneeUpdate.completed_estimates ?? 0; + pointsAssignee.pending_estimates += assigneeUpdate.pending_estimates ?? 0; + pointsAssignee.total_estimates += assigneeUpdate.total_estimates; + } + } + + for (const labelUpdate of labelUpdates) { + const { id } = labelUpdate; + + // find and update the label issue counts + const issuesLabel = distributionObject.distribution?.labels?.find((label) => label.label_id === id); + if (issuesLabel) { + issuesLabel.completed_issues += labelUpdate.completed_issues ?? 0; + issuesLabel.pending_issues += labelUpdate.pending_issues ?? 0; + issuesLabel.total_issues += labelUpdate.total_issues; + } + + // find and update the label points + const pointsLabel = distributionObject.estimate_distribution?.labels?.find((label) => label.label_id === id); + if (pointsLabel) { + pointsLabel.completed_estimates += labelUpdate.completed_estimates ?? 0; + pointsLabel.pending_estimates += labelUpdate.pending_estimates ?? 0; + pointsLabel.total_estimates += labelUpdate.total_estimates; + } + } +};