diff --git a/src/components/GoalBadge.tsx b/src/components/GoalBadge.tsx index abd531bd1..b624d6297 100644 --- a/src/components/GoalBadge.tsx +++ b/src/components/GoalBadge.tsx @@ -1,6 +1,6 @@ import React, { ComponentProps } from 'react'; import { nullable } from '@taskany/bricks'; -import { Badge } from '@taskany/bricks/harmony'; +import { Badge, CircleProgressBar } from '@taskany/bricks/harmony'; import { NextLink } from './NextLink'; import { StateDot } from './StateDot/StateDot'; @@ -12,16 +12,30 @@ interface GoalBadgeProps extends Omit, 'co strike?: boolean; children?: React.ReactNode; className?: string; + progress?: number | null; onClick?: React.MouseEventHandler; } -export const GoalBadge: React.FC = ({ href, title, children, className, onClick, state, ...attrs }) => { +export const GoalBadge: React.FC = ({ + href, + title, + children, + className, + onClick, + state, + progress, + ...attrs +}) => { return ( ( - - ))} + iconLeft={nullable( + progress, + (value) => ( + + ), + nullable(state, (s) => ), + )} iconRight={children} text={nullable( href, diff --git a/src/components/GoalCriteria/GoalCriteria.module.css b/src/components/GoalCriteria/GoalCriteria.module.css index 79ffb7ba5..e779b019f 100644 --- a/src/components/GoalCriteria/GoalCriteria.module.css +++ b/src/components/GoalCriteria/GoalCriteria.module.css @@ -12,6 +12,7 @@ .GoalCriteriaTable { margin-top: var(--gap-sm); + margin-bottom: var(--gap-s); } .GoalCriteriaItemTitle { @@ -87,4 +88,20 @@ .GoalCriteriaIsolate::after { content: none; display: none; +} + +.GoalCriteriaProgress { + position: absolute; + bottom: -10px; + left: 0; + width: 100%; + z-index: 0; +} + +.GoalCriteriaTitleCell { + position: relative; +} + +.GoalCriteriaTable { + gap: var(--gap-sm); } \ No newline at end of file diff --git a/src/components/GoalCriteria/GoalCriteria.tsx b/src/components/GoalCriteria/GoalCriteria.tsx index cec32f96f..e6d5def1c 100644 --- a/src/components/GoalCriteria/GoalCriteria.tsx +++ b/src/components/GoalCriteria/GoalCriteria.tsx @@ -59,6 +59,7 @@ interface GoalCriteriaProps extends CriteriaProps { state?: State | null; project?: string; owner?: ActivityByIdReturnType; + progress?: number | null; }; } @@ -83,6 +84,7 @@ export function mapCriteria< projectId: string | null; owner: ActivityByIdReturnType | null; state: State | null; + completedCriteriaWeight?: number | null; }, >(criteria: T, connectedGoal: G | null): UnionCriteria { if (connectedGoal) { @@ -94,6 +96,7 @@ export function mapCriteria< shortId: connectedGoal._shortId, project: connectedGoal.projectId ?? undefined, owner: connectedGoal.owner ?? undefined, + progress: connectedGoal.completedCriteriaWeight ?? null, }, title: connectedGoal.title, isDone: criteria.isDone, @@ -160,12 +163,13 @@ const GoalCriteria = ({ title, goal, weight, isDone }: Omit - + @@ -417,7 +421,7 @@ export const CriteriaList: React.FC = ({ }) => { const validityData = useCriteriaValidityData(list); return ( - +
{list.map((criteria) => ( ({ content: goal.achievedCriteriaWeight != null && goal.id != null && ( ), - width: 24, + width: 32, }, ], }; diff --git a/src/components/IssueStats/IssueStats.tsx b/src/components/IssueStats/IssueStats.tsx index cbdcea7cc..80ffcb979 100644 --- a/src/components/IssueStats/IssueStats.tsx +++ b/src/components/IssueStats/IssueStats.tsx @@ -78,7 +78,7 @@ export const IssueStats: React.FC = ({ {achivedCriteriaWeight != null && (
- +
)} diff --git a/src/utils/recalculateCriteriaScore.ts b/src/utils/recalculateCriteriaScore.ts index e6b2fa759..e2aac4f7a 100644 --- a/src/utils/recalculateCriteriaScore.ts +++ b/src/utils/recalculateCriteriaScore.ts @@ -1,4 +1,6 @@ -import { Goal, GoalAchieveCriteria, Prisma, PrismaClient, State, StateType } from '@prisma/client'; +import type { Goal, GoalAchieveCriteria, PrismaClient, State } from '@prisma/client'; +import { Prisma, StateType } from '@prisma/client'; +import type { ITXClientDenyList } from '@prisma/client/runtime'; import { prisma } from './prisma'; @@ -7,7 +9,9 @@ export const goalIncludeCriteriaParams = { criteriaGoal: { include: { state: true }, }, - goal: true, + goal: { + include: { state: true }, + }, }, } as const; @@ -19,7 +23,7 @@ interface GoalCriteria extends GoalAchieveCriteria { } export const baseCalcCriteriaWeight = < - G extends { state: { type: StateType } | null }, + G extends { state: { type: StateType } | null; completedCriteriaWeight: number | null }, T extends { deleted: boolean | null; weight: number; isDone: boolean; criteriaGoal: G | null }, >( criteriaList: T[], @@ -37,12 +41,17 @@ export const baseCalcCriteriaWeight = < if (!weight) { anyWithoutWeight += 1; } + if (isDone || criteriaGoal?.state?.type === StateType.Completed) { achivedWithWeight += weight; if (!weight) { comletedWithoutWeight += 1; } + } else if (criteriaGoal != null && criteriaGoal.completedCriteriaWeight != null) { + if (criteriaGoal.completedCriteriaWeight > 0) { + achivedWithWeight += Math.floor((weight / 100) * criteriaGoal.completedCriteriaWeight); + } } } } @@ -62,7 +71,7 @@ export const calcAchievedWeight = (criteriaList: GoalCriteria[]): number => { }; type GoalCalculateScore = Goal & { - goalInCriteria?: Array | null; + goalInCriteria: Array | null; goalAchiveCriteria?: Array | null; }; @@ -82,10 +91,12 @@ type GoalCalculateScore = Goal & { */ export const recalculateCriteriaScore = (goalId: string) => { - let currentGoal: GoalCalculateScore | null; + let currentGoal: GoalCalculateScore; + let countsToUpdate: number; + let count = 0; const getCurrentGoal = async () => { - if (!currentGoal) { - currentGoal = await prisma.goal.findUnique({ + if (!currentGoal || countsToUpdate > count++) { + currentGoal = await prisma.goal.findUniqueOrThrow({ where: { id: goalId }, include: { goalInCriteria: goalIncludeCriteriaParams, @@ -97,20 +108,15 @@ export const recalculateCriteriaScore = (goalId: string) => { return currentGoal; }; - let prismaCtx: Omit; + let prismaCtx: Omit; // eslint-disable-next-line @typescript-eslint/no-explicit-any const promisesChain: (() => Promise)[] = []; - const updateGoalScore = ({ id, score }: { id: string; score: number | null }, includeParams = false) => { + const updateGoalScore = ({ id, score }: { id: string; score: number | null }) => { return prismaCtx.goal.update({ where: { id }, data: { completedCriteriaWeight: score }, - include: includeParams - ? { - goalInCriteria: goalIncludeCriteriaParams, - } - : null, }); }; @@ -119,17 +125,14 @@ export const recalculateCriteriaScore = (goalId: string) => { promisesChain.push(async () => { const goal = await getCurrentGoal(); - if (goal) { - const { goalAchiveCriteria: list, id: goalId } = goal; - let score: number | null = null; - if (list?.length && list.some(({ deleted }) => !deleted)) { - score = calcAchievedWeight(list); - } + const { goalAchiveCriteria: list, id: goalId } = goal; + let score: number | null = null; - currentGoal = await updateGoalScore({ id: goalId, score }, true); - - return currentGoal; + if (list?.length && list.some(({ deleted }) => !deleted)) { + score = calcAchievedWeight(list); } + + await updateGoalScore({ id: goalId, score }); }); return methods; @@ -137,51 +140,46 @@ export const recalculateCriteriaScore = (goalId: string) => { recalcAverageProjectScore: () => { promisesChain.push(async () => { const goal = await getCurrentGoal(); + const projectIds = new Set(); - if (goal) { - const projectIds = new Set(); - - if (goal.projectId) { - projectIds.add(goal.projectId); - } - - if (goal.goalInCriteria?.length) { - goal.goalInCriteria.forEach(({ goal }) => { - if (goal?.projectId) { - projectIds.add(goal.projectId); - } - }); - } - - if (!projectIds.size) { - return; - } - - const countsRequests = Prisma.sql` - select - goal."projectId", - avg(case - when goal."completedCriteriaWeight" is not null and goal."completedCriteriaWeight" > 0 then goal."completedCriteriaWeight" - when state.type = '${Prisma.raw( - StateType.Completed, - )}' and goal."completedCriteriaWeight" is null then 100 - else 0 - end)::int - from "Goal" as goal - inner join "State" as state on goal."stateId" = state.id - where goal."projectId" in (${Prisma.join( - Array.from(projectIds), - )}) and goal."archived" is not true - group by 1 - `; + if (goal.projectId) { + projectIds.add(goal.projectId); + } - return prismaCtx.$executeRaw` - update "Project" as project - set "averageScore" = scoreByProject.score - from (${countsRequests}) as scoreByProject(projectId, score) - where project.id = scoreByProject.projectId - `; + if (goal.goalInCriteria?.length) { + goal.goalInCriteria.forEach(({ goal }) => { + if (goal?.projectId) { + projectIds.add(goal.projectId); + } + }); + } + + if (!projectIds.size) { + return; } + + const countsRequests = Prisma.sql` + select + goal."projectId", + avg(case + when goal."completedCriteriaWeight" is not null and goal."completedCriteriaWeight" > 0 then goal."completedCriteriaWeight" + when state.type = '${Prisma.raw( + StateType.Completed, + )}' and goal."completedCriteriaWeight" is null then 100 + else 0 + end)::int + from "Goal" as goal + inner join "State" as state on goal."stateId" = state.id + where goal."projectId" in (${Prisma.join(Array.from(projectIds))}) and goal."archived" is not true + group by 1 + `; + + return prismaCtx.$executeRaw` + update "Project" as project + set "averageScore" = scoreByProject.score + from (${countsRequests}) as scoreByProject(projectId, score) + where project.id = scoreByProject.projectId + `; }); return methods; @@ -190,56 +188,58 @@ export const recalculateCriteriaScore = (goalId: string) => { promisesChain.push(async () => { const goal = await getCurrentGoal(); - if (goal) { - const { goalInCriteria } = goal; + const { goalInCriteria = [] } = goal; - const goalIdsToUpdate = goalInCriteria?.reduce((acc, { goalId }) => { - acc.push(goalId); + const goalIdsToUpdate = goalInCriteria?.reduce((acc, { goalId }) => { + acc.push(goalId); - return acc; - }, []); - - if (goalIdsToUpdate?.length) { - const criteriaList = await prisma.goalAchieveCriteria.findMany({ - where: { - goalId: { in: goalIdsToUpdate }, - AND: { - OR: [{ deleted: false }, { deleted: null }], - }, + return acc; + }, []); + + if (goalIdsToUpdate?.length) { + const criteriaList = await prismaCtx.goalAchieveCriteria.findMany({ + where: { + goalId: { in: goalIdsToUpdate }, + AND: { + OR: [{ deleted: false }, { deleted: null }], }, - include: { - criteriaGoal: { - include: { state: true }, - }, - goal: { - include: { state: true }, - }, + }, + include: { + criteriaGoal: { + include: { state: true }, }, - }); - - const groupedCriteriaListByGoals = criteriaList.reduce< - Record> - >((acc, criteria) => { - if (!acc[criteria.goalId]) { - acc[criteria.goalId] = []; - } - - acc[criteria.goalId].push(criteria); - - return acc; - }, {}); - - return Promise.all( - Object.entries(groupedCriteriaListByGoals).map(([goalId, list]) => { - let score: number | null = null; - if (list.length && list.some(({ deleted }) => deleted == null || deleted === false)) { - score = calcAchievedWeight(list); - } - - return updateGoalScore({ id: goalId, score }); - }), - ); - } + goal: { + include: { state: true }, + }, + }, + }); + + const groupedCriteriaListByGoals = criteriaList.reduce< + Record> + >((acc, criteria) => { + if (!acc[criteria.goalId]) { + acc[criteria.goalId] = []; + } + + acc[criteria.goalId].push(criteria); + + return acc; + }, {}); + + const values = Prisma.join( + Object.entries(groupedCriteriaListByGoals).map(([id, list]) => + Prisma.join([id, calcAchievedWeight(list)], ',', '(', ')'), + ), + ); + + const tempTableValues = Prisma.sql`(VALUES${values}) AS criteria(goalId, score)`; + + await prismaCtx.$executeRaw` + UPDATE "Goal" AS goal + SET "completedCriteriaWeight" = criteria.score + FROM ${tempTableValues} + WHERE goal.id = criteria.goalId; + `; } }); @@ -248,6 +248,7 @@ export const recalculateCriteriaScore = (goalId: string) => { async run() { return prisma.$transaction((ctx) => { prismaCtx = ctx; + countsToUpdate = promisesChain.length; return promisesChain.reduce((promise, getter) => promise.then(getter), Promise.resolve()); }); diff --git a/trpc/queries/criteria.ts b/trpc/queries/criteria.ts index 7cc6384ee..a4da33c7b 100644 --- a/trpc/queries/criteria.ts +++ b/trpc/queries/criteria.ts @@ -51,6 +51,7 @@ export const criteriaQuery = (params: CriteriaParams = {}) => { _shortId: sql`concat(${ref('criteriaGoal.projectId')}, '-', ${ref( 'criteriaGoal.scopeId', )})`, + completedCriteriaWeight: sql`"criteriaGoal"."completedCriteriaWeight"`, state: fn.toJson('state'), owner: jsonBuildObject({ id: sql`activity.id`, diff --git a/trpc/router/goal.ts b/trpc/router/goal.ts index 4895bfed6..64731e44e 100644 --- a/trpc/router/goal.ts +++ b/trpc/router/goal.ts @@ -1311,6 +1311,7 @@ export const goal = router({ await recalculateCriteriaScore(currentCriteria.goalId) .recalcCurrentGoalScore() + .recalcLinkedGoalsScores() .recalcAverageProjectScore() .run(); } catch (error: any) {