From af2b0155a5837edc99084e7f595776dcf666ab22 Mon Sep 17 00:00:00 2001 From: Luiz Henrique Guerra Date: Tue, 17 Dec 2024 16:28:21 -0300 Subject: [PATCH] feat: add risk cards to project simulation --- app/graphql/types/project_simulation_type.rb | 3 + app/graphql/types/project_type.rb | 20 ++- .../src/components/Projects/ProjectPage.tsx | 114 ++++++------------ app/spa/src/hooks/useProjectInfo.ts | 20 +-- .../project/components/ProjectRiskCards.tsx | 59 +++++++++ .../components/ProjectRiskSimulation.tsx | 35 ++++-- app/spa/src/modules/project/project.types.ts | 7 +- spec/graphql/types/query_type_spec.rb | 33 +++++ 8 files changed, 190 insertions(+), 101 deletions(-) create mode 100644 app/spa/src/modules/project/components/ProjectRiskCards.tsx diff --git a/app/graphql/types/project_simulation_type.rb b/app/graphql/types/project_simulation_type.rb index 552131da8..5846a07d1 100644 --- a/app/graphql/types/project_simulation_type.rb +++ b/app/graphql/types/project_simulation_type.rb @@ -2,6 +2,9 @@ module Types class ProjectSimulationType < Types::BaseObject + field :operational_risk, Float, null: true + field :team_operational_risk, Float, null: true + field :current_monte_carlo_weeks_max, Float, null: true field :current_monte_carlo_weeks_min, Float, null: true field :current_monte_carlo_weeks_std_dev, Float, null: true diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 58a2f153d..be277be41 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -66,6 +66,7 @@ class ProjectType < Types::BaseObject field :project_consolidations_weekly, [Types::ProjectConsolidationType], null: true field :project_members, [Types::ProjectMemberType], null: true field :project_simulation, Types::ProjectSimulationType, null: true do + argument :end_date, GraphQL::Types::ISO8601Date, required: true argument :remaining_work, Int, required: true argument :throughputs, [Int], required: true end @@ -110,11 +111,16 @@ def unscored_demands object.demands.kept.unscored_demands end - def project_simulation(remaining_work:, throughputs:) + def project_simulation(remaining_work:, throughputs:, end_date:) project_based_montecarlo_durations = Stats::StatisticsService.instance.run_montecarlo(remaining_work, throughputs, 500) team_based_montecarlo_durations = compute_team_monte_carlo_weeks(remaining_work, throughputs) + operational_risk, team_operational_risk = operational_risk_info(end_date, project_based_montecarlo_durations, team_based_montecarlo_durations) + { + operational_risk: operational_risk, + team_operational_risk: team_operational_risk, + monte_carlo_p80: Stats::StatisticsService.instance.percentile(80, project_based_montecarlo_durations), current_monte_carlo_weeks_max: project_based_montecarlo_durations.max, current_monte_carlo_weeks_min: project_based_montecarlo_durations.min, @@ -304,11 +310,19 @@ def lead_time_breakdown private + def operational_risk_info(end_date, project_based_montecarlo_durations, team_based_montecarlo_durations) + remaining_weeks = ((end_date - Time.zone.today) / 7).ceil + operational_risk = 1 - Stats::StatisticsService.instance.compute_odds_to_deadline(remaining_weeks, project_based_montecarlo_durations) + team_operational_risk = 1 - Stats::StatisticsService.instance.compute_odds_to_deadline(remaining_weeks, team_based_montecarlo_durations) + + [operational_risk, team_operational_risk] + end + def compute_team_monte_carlo_weeks(remaining_work, throughputs) team = object.team - project_wip = object.max_work_in_progress || 1 - team_wip = team.max_work_in_progress || 1 + project_wip = [object.max_work_in_progress, 1].max + team_wip = [team.max_work_in_progress, 1].max || 1 project_share_in_team_flow = project_wip.to_f / team_wip project_share_team_throughput_data = throughputs.map { |throughput| throughput * project_share_in_team_flow } diff --git a/app/spa/src/components/Projects/ProjectPage.tsx b/app/spa/src/components/Projects/ProjectPage.tsx index c40e85c42..5813770b8 100644 --- a/app/spa/src/components/Projects/ProjectPage.tsx +++ b/app/spa/src/components/Projects/ProjectPage.tsx @@ -3,54 +3,12 @@ import { Box } from "@mui/material" import { ReactNode, useContext } from "react" import { useTranslation } from "react-i18next" import { useLocation, useParams } from "react-router-dom" -import Card, { CardType } from "../Card" import { MessagesContext } from "../../contexts/MessageContext" import useProjectInfo from "../../hooks/useProjectInfo" import ActionMenu from "../menu/ActionMenu" import BasicPage from "../BasicPage" import { Tabs } from "../Tabs" - -export const PROJECT_STANDARD_FRAGMENT = gql` - fragment ProjectStandardFragment on Project { - id - name - company { - id - name - slug - } - } -` - -type ProjectPageProps = { - pageName: string - children: ReactNode - loading?: boolean -} - -const cardTypeByRisk = (risk: number) => { - if (risk > 0.5 && risk <= 0.7) { - return CardType.WARNING - } else if (risk > 0.7) { - return CardType.ERROR - } - - return CardType.SUCCESS -} - -const GENERATE_PROJECT_CACHE_MUTATION = gql` - mutation GenerateProjectCache($projectId: ID!) { - generateProjectCache(projectId: $projectId) { - statusMessage - } - } -` - -type ProjectCacheDTO = { - generateProjectCache?: { - statusMessage: string - } -} +import ProjectRiskCards from "../../modules/project/components/ProjectRiskCards" export const ProjectPage = ({ pageName, @@ -123,16 +81,6 @@ export const ProjectPage = ({ }, ] - const currentOperationalRisk = projectInfo?.currentRiskToDeadline || 0 - const currentRiskToDeadlinePercentage = ( - currentOperationalRisk * 100 - ).toFixed(2) - const remainingDays = projectInfo?.remainingDays - const currentTeamRisk = projectInfo?.currentTeamBasedRisk || 0 - const currentTeamRiskPercentage = (currentTeamRisk * 100).toFixed(2) - const cardTypeTeamRisk = cardTypeByRisk(currentTeamRisk) - const cardTypeOperationalRisk = cardTypeByRisk(currentOperationalRisk) - const actions = [ { name: t("settings_actions.update_cache"), @@ -163,27 +111,12 @@ export const ProjectPage = ({ loading={loading || queryLoading} > <> - {projectIsRunning && ( - - - - - + {projectIsRunning && projectInfo && ( + )} ) } + +export const PROJECT_STANDARD_FRAGMENT = gql` + fragment ProjectStandardFragment on Project { + id + name + endDate + company { + id + name + slug + } + } +` + +type ProjectPageProps = { + pageName: string + children: ReactNode + loading?: boolean +} + +const GENERATE_PROJECT_CACHE_MUTATION = gql` + mutation GenerateProjectCache($projectId: ID!) { + generateProjectCache(projectId: $projectId) { + statusMessage + } + } +` + +type ProjectCacheDTO = { + generateProjectCache?: { + statusMessage: string + } +} diff --git a/app/spa/src/hooks/useProjectInfo.ts b/app/spa/src/hooks/useProjectInfo.ts index 61685ac13..ccc88e547 100644 --- a/app/spa/src/hooks/useProjectInfo.ts +++ b/app/spa/src/hooks/useProjectInfo.ts @@ -1,4 +1,5 @@ import { gql, useQuery } from "@apollo/client" +import { Project } from "../modules/project/project.types" export const PROJECT_QUERY = gql` query ProjectInfo($projectId: ID!) { @@ -9,6 +10,7 @@ export const PROJECT_QUERY = gql` remainingDays currentTeamBasedRisk running + endDate company { id name @@ -18,24 +20,10 @@ export const PROJECT_QUERY = gql` } ` -type ProjectInfo = { - project: { - id: number - name: string - currentRiskToDeadline: number - remainingDays: number - currentTeamBasedRisk: number - running: boolean - company: { - id: string - name: string - slug: string - } - } +type ProjectInfoDTO = { + project?: Project } -type ProjectInfoDTO = ProjectInfo | undefined - const useProjectInfo = (projectId: string) => { const { data, loading, error } = useQuery(PROJECT_QUERY, { variables: { projectId }, diff --git a/app/spa/src/modules/project/components/ProjectRiskCards.tsx b/app/spa/src/modules/project/components/ProjectRiskCards.tsx new file mode 100644 index 000000000..10f433cb5 --- /dev/null +++ b/app/spa/src/modules/project/components/ProjectRiskCards.tsx @@ -0,0 +1,59 @@ +import Card, { CardType } from "../../../components/Card" +import { Box } from "@mui/material" +import { useTranslation } from "react-i18next" + +const ProjectRiskCards = ({ + remainingDays, + currentOperationalRisk, + currentTeamRisk, +}: ProjectRiskCardsProps) => { + const { t } = useTranslation(["generalProjectPage"]) + + const currentRiskToDeadlinePercentage = ( + currentOperationalRisk * 100 + ).toFixed(2) + const currentTeamRiskPercentage = (currentTeamRisk * 100).toFixed(2) + const cardTypeTeamRisk = cardTypeByRisk(currentTeamRisk) + const cardTypeOperationalRisk = cardTypeByRisk(currentOperationalRisk) + + return ( + + + + + + ) +} + +type ProjectRiskCardsProps = { + remainingDays: number + currentOperationalRisk: number + currentTeamRisk: number +} + +const cardTypeByRisk = (risk: number) => { + if (risk > 0.5 && risk <= 0.7) { + return CardType.WARNING + } else if (risk > 0.7) { + return CardType.ERROR + } + + return CardType.SUCCESS +} + +export default ProjectRiskCards diff --git a/app/spa/src/modules/project/components/ProjectRiskSimulation.tsx b/app/spa/src/modules/project/components/ProjectRiskSimulation.tsx index 60a35f5ff..efde0f9dc 100644 --- a/app/spa/src/modules/project/components/ProjectRiskSimulation.tsx +++ b/app/spa/src/modules/project/components/ProjectRiskSimulation.tsx @@ -13,13 +13,14 @@ import { FieldValues, useForm } from "react-hook-form" import { gql, useLazyQuery } from "@apollo/client" import ProjectMonteCarloData from "./ProjectMonteCarloData" import ProjectMonteCarloTeamData from "./ProjectMonteCarloTeamData" +import ProjectRiskCards from "./ProjectRiskCards" +import { differenceInDays, parseISO } from "date-fns" const ProjectRiskSimulation = ({ project }: ProjectRiskSimulationProps) => { const { register, handleSubmit } = useForm() - const [simulateProjectRisk, { data }] = useLazyQuery( - PROJECT_SIMULATION_QUERY - ) + const [simulateProjectRisk, { data, variables }] = + useLazyQuery(PROJECT_SIMULATION_QUERY) const onSubmit = (values: FieldValues) => { simulateProjectRisk({ @@ -27,11 +28,16 @@ const ProjectRiskSimulation = ({ project }: ProjectRiskSimulationProps) => { projectId: project.id, remainingWork: Number(values.remainingWork || 0), throughputs: values.throughputs.split(",").map(Number), + endDate: values.endDate, }, }) } const projectSimulation = data?.project?.projectSimulation + const remainingDays = differenceInDays( + parseISO(variables?.endDate), + new Date() + ) return ( @@ -77,12 +83,22 @@ const ProjectRiskSimulation = ({ project }: ProjectRiskSimulationProps) => { {projectSimulation && project && ( <> + + @@ -113,13 +129,18 @@ const PROJECT_SIMULATION_QUERY = gql` $projectId: ID! $remainingWork: Int! $throughputs: [Int!]! + $endDate: ISO8601Date! ) { project(id: $projectId) { id projectSimulation( remainingWork: $remainingWork throughputs: $throughputs + endDate: $endDate ) { + operationalRisk + teamOperationalRisk + currentMonteCarloWeeksMin currentMonteCarloWeeksMax monteCarloP80 diff --git a/app/spa/src/modules/project/project.types.ts b/app/spa/src/modules/project/project.types.ts index 740ecf097..e8c2c1b03 100644 --- a/app/spa/src/modules/project/project.types.ts +++ b/app/spa/src/modules/project/project.types.ts @@ -113,7 +113,12 @@ export type Project = { } type ProjectSimulation = { - id: string + operationalRisk?: number + teamOperationalRisk?: number + currentMonteCarloWeeksMin?: number + currentMonteCarloWeeksMax?: number + monteCarloP80?: number + currentMonteCarloWeeksStdDev?: number teamMonteCarloWeeksMin?: number teamMonteCarloWeeksMax?: number teamMonteCarloP80?: number diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index 5e2470f0c..3852b3f04 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -797,6 +797,39 @@ end end end + + context 'with project simulation' do + it 'returns the project simulation given the constraints' do + team = Fabricate :team, max_work_in_progress: 2 + project = Fabricate :project, team: team + remaining_work = 100 + throughputs = [5, 10, 15, 20, 10] + + query = + %(query { + project(id: #{project.id}) { + id + projectSimulation(remainingWork: #{remaining_work}, throughputs: #{throughputs}, endDate: "2022-12-31") { + operationalRisk + teamOperationalRisk + monteCarloP80 + currentMonteCarloWeeksMax + currentMonteCarloWeeksMin + currentMonteCarloWeeksStdDev + teamMonteCarloP80 + teamMonteCarloWeeksMax + teamMonteCarloWeeksMin + teamMonteCarloWeeksStdDev + } + } + }) + + expect(Stats::StatisticsService.instance).to(receive(:run_montecarlo)).once.with(remaining_work, [12.5, 25.0, 37.5, 50.0, 25.0], 500).and_return([]) + expect(Stats::StatisticsService.instance).to(receive(:run_montecarlo)).once.with(remaining_work, throughputs, 500).and_return([]) + + FlowClimateSchema.execute(query, variables: nil, context: nil).as_json + end + end end describe '#product' do