From 52ffbde6ff6f0ae0af09760a76c00d8f2d76e4ef Mon Sep 17 00:00:00 2001 From: William Petit Date: Thu, 23 Apr 2020 13:29:24 +0200 Subject: [PATCH] Use task categories cost per time unit to calculate global financial preview --- src/components/estimation-range.tsx | 5 +- src/hooks/use-project-reducer.ts | 8 ++-- src/routes/project/estimation-tab.tsx | 36 +++++++------- src/routes/project/financial-preview.tsx | 56 +++++++++++++++++----- src/routes/project/repartition-preview.tsx | 10 ++-- src/routes/project/time-preview.tsx | 6 +-- src/util/stat.ts | 56 ++++++++++++++++++++++ 7 files changed, 130 insertions(+), 47 deletions(-) diff --git a/src/components/estimation-range.tsx b/src/components/estimation-range.tsx index 4054b7c..7341836 100644 --- a/src/components/estimation-range.tsx +++ b/src/components/estimation-range.tsx @@ -7,13 +7,12 @@ import { Estimation } from "../hooks/use-project-estimations"; export interface EstimationRangeProps { project: Project, estimation: Estimation - sdFactor: number } -export const EstimationRange: FunctionalComponent = ({ project, estimation, sdFactor }) => { +export const EstimationRange: FunctionalComponent = ({ project, estimation }) => { const roundUp = getRoundUpEstimations(project); let e: number|string = roundUp ? Math.ceil(estimation.e) : estimation.e; - let sd: number|string = roundUp ? Math.ceil(estimation.sd * sdFactor) : (estimation.sd * sdFactor); + let sd: number|string = roundUp ? Math.ceil(estimation.sd) : estimation.sd; const max = e+sd; const min = Math.max(e-sd, 0); if (!roundUp) { diff --git a/src/hooks/use-project-reducer.ts b/src/hooks/use-project-reducer.ts index 2756357..1c9c9d1 100644 --- a/src/hooks/use-project-reducer.ts +++ b/src/hooks/use-project-reducer.ts @@ -117,12 +117,12 @@ export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskE [action.confidence]: action.value }; - if (estimations.likely <= estimations.optimistic) { - estimations.likely = estimations.optimistic + 1; + if (estimations.likely < estimations.optimistic) { + estimations.likely = estimations.optimistic; } - if (estimations.pessimistic <= estimations.likely) { - estimations.pessimistic = estimations.likely + 1; + if (estimations.pessimistic < estimations.likely) { + estimations.pessimistic = estimations.likely; } return { diff --git a/src/routes/project/estimation-tab.tsx b/src/routes/project/estimation-tab.tsx index 28c0cf5..fd617c3 100644 --- a/src/routes/project/estimation-tab.tsx +++ b/src/routes/project/estimation-tab.tsx @@ -39,30 +39,28 @@ const EstimationTab: FunctionalComponent = ({ project, dispa onTaskRemove={onTaskRemove} onTaskLabelUpdate={onTaskLabelUpdate} onEstimationChange={onEstimationChange} /> -
-
- -
-
- - { - Object.keys(project.tasks).length <= 20 ? -
-
-

⚠️ Attention

-
-

Votre projet ne contient pas assez de tâches pour que les niveaux de confiance soient fiables.

-
-

Un minimum de 20 tâches est conseillé pour obtenir une estimation pertinente.

-
-
: - null - } +
+
+
+ +
+
+ { + Object.keys(project.tasks).length <= 20 ? +
+
+

⚠️ Attention

+

Votre projet ne contient pas assez de tâches pour que les niveaux de confiance soient fiables. Un minimum de 20 tâches est conseillé pour obtenir une estimation pertinente.

+
+
: + null + } +
); }; diff --git a/src/routes/project/financial-preview.tsx b/src/routes/project/financial-preview.tsx index a820eca..1970411 100644 --- a/src/routes/project/financial-preview.tsx +++ b/src/routes/project/financial-preview.tsx @@ -1,7 +1,10 @@ import { FunctionalComponent, h } from "preact"; import { Project } from "../../models/project"; import { useProjectEstimations } from "../../hooks/use-project-estimations"; -import { getCurrency } from "../../models/params"; +import { getCurrency, defaults, getTaskCategoryCost } from "../../models/params"; +import { getMinMaxCosts, Cost } from "../../util/stat"; +import * as style from './style.module.css'; +import ProjectTimeUnit from "../../components/project-time-unit"; export interface FinancialPreviewProps { project: Project @@ -9,15 +12,17 @@ export interface FinancialPreviewProps { const FinancialPreview: FunctionalComponent = ({ project }) => { const estimations = useProjectEstimations(project); - const costPerTimeUnit = 500; - const maxCost = Math.ceil((estimations.p99.e + estimations.p99.sd) * costPerTimeUnit); - const minCost = Math.max(Math.ceil((estimations.p99.e - estimations.p99.sd) * costPerTimeUnit), 0); + const costs = getMinMaxCosts(project, estimations.p99); + return (
- + @@ -28,17 +33,13 @@ const FinancialPreview: FunctionalComponent = ({ project @@ -47,4 +48,37 @@ const FinancialPreview: FunctionalComponent = ({ project ); }; +export interface CostDetailsProps { + project: Project + cost: Cost +} + +export const CostDetails:FunctionalComponent = ({ project, cost }) => { + return ( +
+ + ≈ {cost.totalCost} {getCurrency(project)} + {cost.totalTime} + +
Prévisionnel financier + Prévisionnel financier
+ confiance >= 99.7% +
Temps
Maximum -
- ~ {maxCost} {getCurrency(project)} -
+
Minimum -
- ~ {minCost} {getCurrency(project)} -
+
+ + { + Object.keys(cost.details).map(taskCategoryId => { + const taskCategory = project.params.taskCategories[taskCategoryId]; + const details = cost.details[taskCategoryId]; + return ( + + + + + + ) + }) + } + +
{taskCategory.label}{details.cost} {getCurrency(project)}{details.time} × {getTaskCategoryCost(taskCategory)} {getCurrency(project)}
+ + ); +}; + export default FinancialPreview; diff --git a/src/routes/project/repartition-preview.tsx b/src/routes/project/repartition-preview.tsx index d6b8315..64fb2c7 100644 --- a/src/routes/project/repartition-preview.tsx +++ b/src/routes/project/repartition-preview.tsx @@ -3,14 +3,14 @@ import { Project } from "../../models/project"; import { useProjectEstimations } from "../../hooks/use-project-estimations"; import { getCurrency, getRoundUpEstimations } from "../../models/params"; import ProjectTimeUnit from "../../components/project-time-unit"; -import { getTaskCategoryWeightedMean, getProjectWeightedMean } from "../../util/stat"; +import { getTaskCategoryWeightedMean, getProjectWeightedMean, getTaskCategoriesMeanRepartition } from "../../util/stat"; export interface RepartitionPreviewProps { project: Project } const RepartitionPreview: FunctionalComponent = ({ project }) => { - const projectMean = getProjectWeightedMean(project); + const repartition = getTaskCategoriesMeanRepartition(project); return (
@@ -26,11 +26,7 @@ const RepartitionPreview: FunctionalComponent = ({ proj { Object.values(project.params.taskCategories).map(tc => { - let mean = getTaskCategoryWeightedMean(tc.id, project); - const percent = ((mean/projectMean) * 100).toFixed(0); - if (getRoundUpEstimations(project)) { - mean = Math.ceil(mean); - } + let percent = (repartition[tc.id] * 100).toFixed(0); return ( diff --git a/src/routes/project/time-preview.tsx b/src/routes/project/time-preview.tsx index 19928e3..fd00180 100644 --- a/src/routes/project/time-preview.tsx +++ b/src/routes/project/time-preview.tsx @@ -24,15 +24,15 @@ const TimePreview: FunctionalComponent = ({ project }) => { - + - + - + diff --git a/src/util/stat.ts b/src/util/stat.ts index b26c510..2ccb287 100644 --- a/src/util/stat.ts +++ b/src/util/stat.ts @@ -1,6 +1,8 @@ import { Task, TaskCategory, TaskCategoryID } from "../models/task"; import { Project } from "../models/project"; import { TaskCategoriesTableProps } from "../routes/project/task-categories-table"; +import { Estimation } from "../hooks/use-project-estimations"; +import { getTaskCategoryCost } from "../models/params"; export function getTaskWeightedMean(t: Task): number { return (t.estimations.optimistic + (4*t.estimations.likely) + t.estimations.pessimistic) / 6; @@ -29,4 +31,58 @@ export function getProjectStandardDeviation(p : Project): number { sum += Math.pow(getTaskStandardDeviation(t), 2); return sum; }, 0)); +} + +export interface MeanRepartition { + [id: string]: number +} + +export function getTaskCategoriesMeanRepartition(project: Project): MeanRepartition { + let projectMean = getProjectWeightedMean(project); + const repartition: MeanRepartition = {}; + Object.values(project.params.taskCategories).forEach(tc => { + repartition[tc.id] = getTaskCategoryWeightedMean(tc.id, project) / projectMean; + if (Number.isNaN(repartition[tc.id])) repartition[tc.id] = 0; + }); + return repartition; +} + +export interface MinMaxCost { + max: Cost + min: Cost +} + +export interface Cost { + totalCost: number + totalTime: number + details: { [taskCategoryId: string]: { time: number, cost: number } } +} + +export function getMinMaxCosts(project: Project, estimation: Estimation): MinMaxCost { + const max: Cost = {totalCost: 0, totalTime: 0, details: {}}; + const min: Cost = {totalCost: 0, totalTime: 0, details: {}}; + + const repartition = getTaskCategoriesMeanRepartition(project); + + Object.values(project.params.taskCategories).forEach(tc => { + const cost = getTaskCategoryCost(tc); + + const maxTime = Math.ceil((estimation.e + estimation.sd) * repartition[tc.id]); + max.details[tc.id] = { + time: maxTime, + cost: maxTime * cost, + }; + max.totalTime += max.details[tc.id].time; + max.totalCost += max.details[tc.id].cost; + + const minTime = Math.ceil((estimation.e - estimation.sd) * repartition[tc.id]); + min.details[tc.id] = { + time: minTime, + cost: minTime * cost, + }; + min.totalTime += min.details[tc.id].time; + min.totalCost += min.details[tc.id].cost; + }); + + return { max, min }; } \ No newline at end of file
{tc.label}
>= 99.7%
>= 90%
>= 68%