Frontend/backend project structure

+ Base implementation of a differential synchronization based on Neil
  Fraser article/talk

See https://www.youtube.com/watch?v=S2Hp_1jqpY8
This commit is contained in:
2020-04-27 22:43:42 +02:00
parent 40759f59d6
commit d9fb51394c
89 changed files with 2178 additions and 14 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -0,0 +1,27 @@
import { FunctionalComponent, h } from "preact";
import { Route, Router, RouterOnChangeArgs } from "preact-router";
import Home from "../routes/home/index";
import Project from "../routes/project/index";
import NotFoundPage from '../routes/notfound/index';
import Header from "./header/index";
const App: FunctionalComponent = () => {
let currentUrl: string;
const handleRoute = (e: RouterOnChangeArgs) => {
currentUrl = e.url;
};
return (
<div id="app">
<Header class="noPrint" />
<Router onChange={handleRoute}>
<Route path="/" component={Home} />
<Route path="/p/:projectId" component={Project} />
<NotFoundPage default />
</Router>
</div>
);
};
export default App;

View File

@ -0,0 +1,55 @@
import { FunctionalComponent, h, Component, ComponentChild, Fragment } from "preact";
import * as style from "./style.module.css";
import { useState, useEffect } from "preact/hooks";
export interface EditableTextProps {
value: string
class?: string
editIconClass?: string
onChange?: (value: string) => void
render?: (value: string) => ComponentChild
}
const EditableText: FunctionalComponent<EditableTextProps> = ({ onChange, value, render, ...props }) => {
const [ internalValue, setInternalValue ] = useState(value);
const [ editMode, setEditMode ] = useState(false);
useEffect(() => {
if (onChange) onChange(internalValue);
}, [internalValue]);
const onEditIconClick = () => {
setEditMode(true);
};
const onValidateButtonClick = () => {
setEditMode(false);
}
const onValueChange = (evt: Event) => {
const currentTarget = evt.currentTarget as HTMLInputElement;
setInternalValue(currentTarget.value);
};
return (
<div class={`${style.editableText} ${props.class ? props.class : ''}`}>
{
editMode ?
<div class="field has-addons">
<div class="control">
<input class="input is-expanded" type="text" value={internalValue} onChange={onValueChange} />
</div>
<div class="control">
<a class="button" onClick={onValidateButtonClick}></a>
</div>
</div> :
<Fragment>
{ render ? render(internalValue) : <span>{internalValue}</span> }
<i class={`${style.editIcon} icon ${props.editIconClass ? props.editIconClass : ''}`} onClick={onEditIconClick}>🖋</i>
</Fragment>
}
</div>
);
};
export default EditableText;

View File

@ -0,0 +1,17 @@
.editableText {
display: inline-block;
}
.editableText > * {
display: inline-block;
}
.editIcon {
visibility: hidden;
margin-left: 0.25em;
cursor: pointer;
}
.editableText:hover > .editIcon {
visibility: visible;
}

View File

@ -0,0 +1,13 @@
declare namespace StyleModuleCssModule {
export interface IStyleModuleCss {
editIcon: string;
editableText: string;
}
}
declare const StyleModuleCssModule: StyleModuleCssModule.IStyleModuleCss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss;
};
export = StyleModuleCssModule;

View File

@ -0,0 +1,35 @@
import ProjectTimeUnit from "./project-time-unit";
import { defaults, getRoundUpEstimations } from "../models/params";
import { Project } from "../models/project";
import { FunctionalComponent, Fragment, h } from "preact";
import { Estimation } from "../hooks/use-project-estimations";
export interface EstimationRangeProps {
project: Project,
estimation: Estimation
}
export const EstimationRange: FunctionalComponent<EstimationRangeProps> = ({ project, estimation }) => {
const roundUp = getRoundUpEstimations(project);
let e: number|string = estimation.e;
let sd: number|string = estimation.sd;
let max = e+sd;
let min = Math.max(e-sd, 0);
if (roundUp) {
sd = Math.ceil(sd);
e = Math.ceil(e);
max = Math.ceil(max);
min = Math.ceil(min);
} else {
sd = sd.toFixed(2);
e = e.toFixed(2);
}
return (
<Fragment>
<abbr title={`max: ${max.toFixed(2)}, min: ${min.toFixed(2)}`}>{`${e} ± ${sd}`}</abbr>&nbsp;<ProjectTimeUnit project={project} />
</Fragment>
);
}
export default EstimationRange;

View File

@ -0,0 +1,31 @@
import { FunctionalComponent, h } from "preact";
import style from "./style.module.css";
export interface HeaderProps {
class?: string
}
const Header: FunctionalComponent<HeaderProps> = ({ ...props}) => {
return (
<div class={`container ${style.header} ${props.class ? props.class : ''}`}>
<div class="columns">
<div class="column">
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<h1 class="title is-size-4"> Guesstimate</h1>
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
</nav>
</div>
</div>
</div>
);
};
export default Header;

View File

@ -0,0 +1,3 @@
.header {
display: inherit;
}

View File

@ -0,0 +1,12 @@
declare namespace StyleModuleCssModule {
export interface IStyleModuleCss {
header: string;
}
}
declare const StyleModuleCssModule: StyleModuleCssModule.IStyleModuleCss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss;
};
export = StyleModuleCssModule;

View File

@ -0,0 +1,16 @@
import { FunctionalComponent, h } from "preact";
import { Project } from "../models/project";
import { getTimeUnit } from "../models/params";
export interface ProjectTimeUnitProps {
project: Project
}
const ProjectTimeUnit: FunctionalComponent<ProjectTimeUnitProps> = ({ project }) => {
const timeUnit = getTimeUnit(project);
return (
<abbr title={timeUnit.label}>{timeUnit.acronym}</abbr>
);
};
export default ProjectTimeUnit;

View File

@ -0,0 +1,50 @@
import { FunctionalComponent, h, ComponentChild } from "preact";
import style from "./style.module.css";
import { useState } from "preact/hooks";
export interface TabItem {
label: string
icon?: string
render: () => ComponentChild
}
export interface TabsProps {
class?: string
items: TabItem[]
}
const Tabs: FunctionalComponent<TabsProps> = ({ items, ...props }) => {
const [ selectedTabIndex, setSelectedTabIndex ] = useState(0);
const onTabClick = (tabIndex: number) => {
setSelectedTabIndex(tabIndex);
};
const selectedTab = items[selectedTabIndex];
return (
<div class={`${style.tabs} ${props.class}`}>
<div class="tabs">
<ul class={`noPrint`}>
{
items.map((tabItem, tabIndex) => (
<li key={`tab-${tabIndex}`}
onClick={onTabClick.bind(null, tabIndex)}
class={`${selectedTabIndex === tabIndex ? 'is-active' : ''}`}>
<a>
<span class="icon is-small">{tabItem.icon}</span>
{tabItem.label}
</a>
</li>
))
}
</ul>
</div>
<div class={style.tabContent}>
{ selectedTab.render() }
</div>
</div>
);
};
export default Tabs;

View File

@ -0,0 +1,8 @@
.tabs {
display: inherit;
}
.tabContent {
padding-top: 1em;
max-width: 100%;
}

View File

@ -0,0 +1,13 @@
declare namespace StyleModuleCssModule {
export interface IStyleModuleCss {
tabContent: string;
tabs: string;
}
}
declare const StyleModuleCssModule: StyleModuleCssModule.IStyleModuleCss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss;
};
export = StyleModuleCssModule;

View File

@ -0,0 +1,37 @@
import { useState } from "preact/hooks";
export function useLocalStorage<T>(key: string, initialValue: T) {
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState(() => {
try {
// Get from local storage by key
const item = window.localStorage.getItem(key);
// Parse stored json or if none return initialValue
return item ? JSON.parse(item) : initialValue;
} catch (error) {
// If error also return initialValue
console.error(error);
return initialValue;
}
});
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue = (value: T) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
// Save state
setStoredValue(valueToStore);
// Save to local storage
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
// A more advanced implementation would handle the error case
console.error(error);
}
};
return [storedValue, setValue];
}

View File

@ -0,0 +1,41 @@
import { useEffect, useState } from "preact/hooks";
export function useMediaQuery(query: string): boolean {
const media = window.matchMedia(query);
const [ matches, setMatches ] = useState(media.matches);
useEffect(() => {
const listener = (evt: MediaQueryListEvent) => {
setMatches(evt.matches);
};
media.addListener(listener);
return () => {media.removeListener(listener)}
}, []);
return matches;
}
export function usePrintMediaQuery(): boolean {
const isMediaQueryPrint = useMediaQuery("print");
const [ isPrint, setIsPrint ] = useState(false);
// Firefox/IE compatibility layer
useEffect(() => {
const beforePrint = () => {
setIsPrint(true);
};
const afterPrint = () => {
setIsPrint(false);
};
window.addEventListener('beforeprint', beforePrint);
window.addEventListener('afterprint', afterPrint);
return () => {
window.removeEventListener('beforeprint', beforePrint);
window.removeEventListener('afterprint', afterPrint);
};
}, []);
return isMediaQueryPrint || isPrint;
}

View File

@ -0,0 +1,11 @@
import { useEffect, useRef } from "preact/hooks";
export function usePrevious<T>(value: T): T|undefined {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current as T|undefined;
}

View File

@ -0,0 +1,35 @@
import { Project } from "../models/project";
import { useState, useEffect } from "preact/hooks";
import { getProjectWeightedMean, getProjectStandardDeviation } from "../util/stat";
export interface Estimation {
e: number
sd: number
}
export interface ProjetEstimations {
p99: Estimation
p90: Estimation
p68: Estimation
}
export function useProjectEstimations(p :Project): ProjetEstimations {
const [ estimations, setEstimations ] = useState({
p99: { e: 0, sd: 0 },
p90: { e: 0, sd: 0 },
p68: { e: 0, sd: 0 },
});
useEffect(() => {
const projectWeightedMean = getProjectWeightedMean(p)
const projectStandardDeviation = getProjectStandardDeviation(p);
setEstimations({
p99: { e: projectWeightedMean, sd: (projectStandardDeviation * 3) },
p90: { e: projectWeightedMean, sd: (projectStandardDeviation * 1.645) },
p68: { e: projectWeightedMean, sd: (projectStandardDeviation) },
})
}, [p.tasks]);
return estimations;
}

View File

@ -0,0 +1,301 @@
import { Project } from "../models/project";
import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from "../models/task";
import { useReducer } from "preact/hooks";
export interface Action {
type: string
}
export type ProjectReducerActions =
AddTask |
RemoveTask |
UpdateTaskEstimation |
UpdateProjectLabel |
UpdateTaskLabel |
UpdateParam |
UpdateTaskCategoryLabel |
UpdateTaskCategoryCost |
AddTaskCategory |
RemoveTaskCategory
export function useProjectReducer(project: Project) {
return useReducer(projectReducer, project);
}
export function projectReducer(project: Project, action: ProjectReducerActions): Project {
console.log(action);
switch(action.type) {
case ADD_TASK:
return handleAddTask(project, action as AddTask);
case REMOVE_TASK:
return handleRemoveTask(project, action as RemoveTask);
case UPDATE_TASK_ESTIMATION:
return handleUpdateTaskEstimation(project, action as UpdateTaskEstimation);
case UPDATE_PROJECT_LABEL:
return handleUpdateProjectLabel(project, action as UpdateProjectLabel);
case UPDATE_TASK_LABEL:
return handleUpdateTaskLabel(project, action as UpdateTaskLabel);
case UPDATE_PARAM:
return handleUpdateParam(project, action as UpdateParam);
case ADD_TASK_CATEGORY:
return handleAddTaskCategory(project, action as AddTaskCategory);
case REMOVE_TASK_CATEGORY:
return handleRemoveTaskCategory(project, action as RemoveTaskCategory);
case UPDATE_TASK_CATEGORY_LABEL:
return handleUpdateTaskCategoryLabel(project, action as UpdateTaskCategoryLabel);
case UPDATE_TASK_CATEGORY_COST:
return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost);
}
return project;
}
export interface AddTask extends Action {
task: Task
}
export const ADD_TASK = "ADD_TASK";
export function addTask(task: Task): AddTask {
return { type: ADD_TASK, task };
}
export function handleAddTask(project: Project, action: AddTask): Project {
const task = { ...action.task };
return {
...project,
tasks: {
...project.tasks,
[task.id]: task,
}
};
}
export interface RemoveTask extends Action {
id: TaskID
}
export const REMOVE_TASK = "REMOVE_TASK";
export function removeTask(id: TaskID): RemoveTask {
return { type: REMOVE_TASK, id };
}
export function handleRemoveTask(project: Project, action: RemoveTask): Project {
const tasks = { ...project.tasks };
delete tasks[action.id];
return {
...project,
tasks
};
}
export interface UpdateTaskEstimation extends Action {
id: TaskID
confidence: string
value: number
}
export const UPDATE_TASK_ESTIMATION = "UPDATE_TASK_ESTIMATION";
export function updateTaskEstimation(id: TaskID, confidence: EstimationConfidence, value: number): UpdateTaskEstimation {
return { type: UPDATE_TASK_ESTIMATION, id, confidence, value };
}
export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskEstimation): Project {
const estimations = {
...project.tasks[action.id].estimations,
[action.confidence]: action.value
};
if (estimations.likely < estimations.optimistic) {
estimations.likely = estimations.optimistic;
}
if (estimations.pessimistic < estimations.likely) {
estimations.pessimistic = estimations.likely;
}
return {
...project,
tasks: {
...project.tasks,
[action.id]: {
...project.tasks[action.id],
estimations: estimations,
}
}
};
}
export interface UpdateProjectLabel extends Action {
label: string
}
export const UPDATE_PROJECT_LABEL = "UPDATE_PROJECT_LABEL";
export function updateProjectLabel(label: string): UpdateProjectLabel {
return { type: UPDATE_PROJECT_LABEL, label };
}
export function handleUpdateProjectLabel(project: Project, action: UpdateProjectLabel): Project {
return {
...project,
label: action.label
};
}
export interface UpdateTaskLabel extends Action {
id: TaskID
label: string
}
export const UPDATE_TASK_LABEL = "UPDATE_TASK_LABEL";
export function updateTaskLabel(id: TaskID, label: string): UpdateTaskLabel {
return { type: UPDATE_TASK_LABEL, id, label };
}
export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel): Project {
return {
...project,
tasks: {
...project.tasks,
[action.id]: {
...project.tasks[action.id],
label: action.label,
}
}
};
}
export interface UpdateParam extends Action {
name: string
value: any
}
export const UPDATE_PARAM = "UPDATE_PARAM";
export function updateParam(name: string, value: any): UpdateParam {
return { type: UPDATE_PARAM, name, value };
}
export function handleUpdateParam(project: Project, action: UpdateParam): Project {
return {
...project,
params: {
...project.params,
[action.name]: action.value,
}
};
}
export interface UpdateTaskCategoryLabel extends Action {
categoryId: TaskCategoryID
label: string
}
export const UPDATE_TASK_CATEGORY_LABEL = "UPDATE_TASK_CATEGORY_LABEL";
export function updateTaskCategoryLabel(categoryId: TaskCategoryID, label: string): UpdateTaskCategoryLabel {
return { type: UPDATE_TASK_CATEGORY_LABEL, categoryId, label };
}
export function handleUpdateTaskCategoryLabel(project: Project, action: UpdateTaskCategoryLabel): Project {
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[action.categoryId]: {
...project.params.taskCategories[action.categoryId],
label: action.label
},
}
}
};
}
export interface UpdateTaskCategoryCost extends Action {
categoryId: TaskCategoryID
costPerTimeUnit: number
}
export const UPDATE_TASK_CATEGORY_COST = "UPDATE_TASK_CATEGORY_COST";
export function updateTaskCategoryCost(categoryId: TaskCategoryID, costPerTimeUnit: number): UpdateTaskCategoryCost {
return { type: UPDATE_TASK_CATEGORY_COST, categoryId, costPerTimeUnit };
}
export function handleUpdateTaskCategoryCost(project: Project, action: UpdateTaskCategoryCost): Project {
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[action.categoryId]: {
...project.params.taskCategories[action.categoryId],
costPerTimeUnit: action.costPerTimeUnit
},
}
}
};
}
export const ADD_TASK_CATEGORY = "ADD_TASK_CATEGORY";
export interface AddTaskCategory extends Action {
taskCategory: TaskCategory
}
export function addTaskCategory(taskCategory: TaskCategory): AddTaskCategory {
return { type: ADD_TASK_CATEGORY, taskCategory };
}
export function handleAddTaskCategory(project: Project, action: AddTaskCategory): Project {
const taskCategory = { ...action.taskCategory };
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[taskCategory.id]: taskCategory,
}
}
};
}
export interface RemoveTaskCategory extends Action {
taskCategoryId: TaskCategoryID
}
export const REMOVE_TASK_CATEGORY = "REMOVE_TASK_CATEGORY";
export function removeTaskCategory(taskCategoryId: TaskCategoryID): RemoveTaskCategory {
return { type: REMOVE_TASK_CATEGORY, taskCategoryId };
}
export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCategory): Project {
const taskCategories = { ...project.params.taskCategories };
delete taskCategories[action.taskCategoryId];
return {
...project,
params: {
...project.params,
taskCategories
}
};
}

View File

@ -0,0 +1,57 @@
import { Project } from "../models/project";
import { usePrevious } from "./use-previous";
import * as jsonpatch from 'fast-json-patch';
import { useEffect, useState } from "preact/hooks";
import { Operation } from "fast-json-patch";
export interface ServerSyncOptions {
baseUrl: string
}
export const defaultOptions = {
baseUrl: `ws://${window.location.host}/ws`,
}
export function useServerSync(project: Project, options: ServerSyncOptions = defaultOptions) {
options = Object.assign({}, defaultOptions, options);
const [ conn, setConn ] = useState<WebSocket>(() => {
const conn = new WebSocket(`${options.baseUrl}/${project.id}`);
conn.onerror = (evt: Event) => {
console.error(evt);
};
conn.onopen = (evt: Event) => {
console.log('ws connection opened');
};
return conn;
});
const [ ops, setOps ] = useState<Operation[][]>([]);
useEffect(() => {
return () => {
if (!conn) return;
console.log('closing ws');
conn.close();
conn.onerror = null;
conn.onopen = null;
};
}, []);
useEffect(() => {
conn.send(JSON.stringify(ops));
setOps([]);
}, [ops.length > 0]);
let previousProject: Project|any = usePrevious(project);
if (!previousProject) previousProject = {};
const newOps = jsonpatch.compare(previousProject, project);
if (ops.length === 0) return;
setOps(ops => [...ops, newOps]);
}

View File

@ -0,0 +1,35 @@
import {Project} from "../models/project";
import { useState } from "preact/hooks";
import { ProjectStorageKeyPrefix } from "../util/storage";
export function loadStoredProjects(): Project[] {
const projects: Project[] = [];
Object.keys(window.localStorage).forEach(key => {
if (key.startsWith(ProjectStorageKeyPrefix)) {
try {
const data = window.localStorage.getItem(key);
if (data) {
const project = JSON.parse(data);
projects.push(project);
}
} catch(err) {
console.error(err);
}
}
});
return projects
}
export function useStoredProjectList(): [Project[], () => void] {
const [ projects, setProjects ] = useState(() => {
return loadStoredProjects();
});
const refresh = () => {
setProjects(loadStoredProjects());
};
return [ projects, refresh];
}

14
client/src/index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= htmlWebpackPlugin.options.title %></title>
<% for (var css in htmlWebpackPlugin.files.css) { %>
<link href="<%= htmlWebpackPlugin.files.css[css] %>" rel="stylesheet">
<% } %>
</head>
<body>
<div id="app"></div>
</body>
</html>

14
client/src/index.js Normal file
View File

@ -0,0 +1,14 @@
import "./style/index.css";
import "bulma/css/bulma.css";
import "bulma-switch/dist/css/bulma-switch.min.css";
import { h, render } from 'preact'
import App from "./components/app";
render(h(App, {}), document.getElementById('app'));
// Hot Module Replacement
if (module.hot) {
require("preact/debug");
module.hot.accept();
}

View File

@ -0,0 +1,71 @@
import { TaskCategory, TaskCategoryID } from "./task";
import { Project } from "./project";
export interface TaskCategoriesIndex {
[id: string]: TaskCategory
}
export interface TimeUnit {
label: string
acronym: string
}
export interface Params {
taskCategories: TaskCategoriesIndex
timeUnit: TimeUnit
currency: string
roundUpEstimations: boolean
hideFinancialPreviewOnPrint: boolean
}
export const defaults = {
taskCategories: {
"RQ15CD3iX1Ey2f9kat7tfLGZmUx9GGc15nS6A7fYtZv76SnS4": {
id: "RQ15CD3iX1Ey2f9kat7tfLGZmUx9GGc15nS6A7fYtZv76SnS4",
label: "Développement",
costPerTimeUnit: 500,
},
"QRdGS5Pr5si9SSjU84WAq19cjxQ3rUL71jKh8oHSMZSY4bBH9": {
id: "QRdGS5Pr5si9SSjU84WAq19cjxQ3rUL71jKh8oHSMZSY4bBH9",
label: "Conduite de projet",
costPerTimeUnit: 500,
},
"RPcqFMLdQrgBSomv7Sao7EQSb7on6rtjfDQK5JZNhNSg9DwEo": {
id: "RPcqFMLdQrgBSomv7Sao7EQSb7on6rtjfDQK5JZNhNSg9DwEo",
label: "Recette",
costPerTimeUnit: 500,
},
},
timeUnit: {
label: "jour/homme",
acronym: "j/h",
},
roundUpEstimations: true,
currency: "€ H.T.",
costPerTimeUnit: 500,
hideFinancialPreviewOnPrint: false,
}
export function getTimeUnit(project: Project): TimeUnit {
return project.params.timeUnit ? project.params.timeUnit : defaults.timeUnit;
}
export function getRoundUpEstimations(project: Project): boolean {
return project.params.hasOwnProperty("roundUpEstimations") ? project.params.roundUpEstimations : defaults.roundUpEstimations;
}
export function getCurrency(project: Project): string {
return project.params.currency ? project.params.currency : defaults.currency;
}
export function getTaskCategories(project: Project): TaskCategoriesIndex {
return project.params.taskCategories ? project.params.taskCategories : defaults.taskCategories;
}
export function getTaskCategoryCost(taskCategory: TaskCategory): number {
return taskCategory.hasOwnProperty("costPerTimeUnit") ? taskCategory.costPerTimeUnit : defaults.costPerTimeUnit;
}
export function getHideFinancialPreviewOnPrint(project: Project): boolean {
return project.params.hasOwnProperty("hideFinancialPreviewOnPrint") ? project.params.hideFinancialPreviewOnPrint : defaults.hideFinancialPreviewOnPrint;
}

View File

@ -0,0 +1,29 @@
import { Task, TaskCategory, TaskID } from './task';
import { Params, defaults } from "./params";
import { uuidV4 } from "../util/uuid";
export type ProjectID = string;
export interface Project {
id: ProjectID
label: string
description: string
tasks: Tasks
params: Params
}
export interface Tasks {
[id: string]: Task
}
export function newProject(id?: string): Project {
return {
id: id ? id : uuidV4(),
label: "",
description: "",
tasks: {},
params: {
...defaults
},
};
}

46
client/src/models/task.ts Normal file
View File

@ -0,0 +1,46 @@
import { uuidV4, base58UUID } from "../util/uuid"
import { defaults } from "./params";
export type TaskID = string
export enum EstimationConfidence {
Optimistic = "optimistic",
Likely = "likely",
Pessimistic = "pessimistic"
}
export interface Task {
id: TaskID
label: string
category: TaskCategoryID
estimations: { [confidence in EstimationConfidence]: number }
}
export type TaskCategoryID = string
export interface TaskCategory {
id: TaskCategoryID
label: string
costPerTimeUnit: number
}
export function newTask(label: string, category: TaskCategoryID): Task {
return {
id: base58UUID(),
label,
category,
estimations: {
[EstimationConfidence.Optimistic]: 0,
[EstimationConfidence.Likely]: 0,
[EstimationConfidence.Pessimistic]: 0,
}
};
}
export function createTaskCategory(): TaskCategory {
return {
id: base58UUID(),
costPerTimeUnit: defaults.costPerTimeUnit,
label: ""
};
}

View File

@ -0,0 +1,57 @@
import { FunctionalComponent, h } from "preact";
import style from "./style.module.css";
import { route } from 'preact-router';
import { base58UUID } from '../../util/uuid';
import { useStoredProjectList } from "../../hooks/use-stored-project-list";
const Home: FunctionalComponent = () => {
const [ projects, refreshProjects ] = useStoredProjectList();
const openNewProject = () => {
const uuid = base58UUID();
route(`/p/${uuid}`);
};
return (
<div class={`container ${style.home}`}>
<div class="columns">
<div class="column">
<div class="buttons is-right">
<button class="button is-primary"
onClick={openNewProject}>
<strong>+</strong>&nbsp;&nbsp;Nouveau projet
</button>
</div>
<div class="panel">
<p class="panel-heading">
Mes projets
</p>
{/* <div class="panel-block">
<p class="control has-icons-left">
<input class="input" type="text" placeholder="Search" />
<span class="icon is-left">🔍</span>
</p>
</div> */}
{
projects.map(p => (
<a class="panel-block" href={`/p/${p.id}`}>
<span class="panel-icon">🗒</span>
{ p.label ? p.label : "Projet sans nom" }
</a>
))
}
{
projects.length === 0 ?
<p class="panel-block">
<div class={style.noProjects}>Aucun project pour l'instant.</div>
</p> :
null
}
</div>
</div>
</div>
</div>
);
};
export default Home;

View File

@ -0,0 +1,9 @@
.home {
height: 100%;
}
.noProjects {
width: 100%;
text-align: center;
font-style: italic;
}

View File

@ -0,0 +1,13 @@
declare namespace StyleModuleCssModule {
export interface IStyleModuleCss {
home: string;
noProjects: string;
}
}
declare const StyleModuleCssModule: StyleModuleCssModule.IStyleModuleCss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss;
};
export = StyleModuleCssModule;

View File

@ -0,0 +1,15 @@
import { FunctionalComponent, h } from "preact";
import { Link } from 'preact-router/match';
import style from "./style.module.css";
const Notfound: FunctionalComponent = () => {
return (
<div class={style.notFound}>
<h1>Error 404</h1>
<p>That page doesn't exist.</p>
<Link href="/"><h4>Back to Home</h4></Link>
</div>
);
};
export default Notfound;

View File

@ -0,0 +1,3 @@
.notFound {
display: inherit;
}

View File

@ -0,0 +1,12 @@
declare namespace StyleModuleCssModule {
export interface IStyleModuleCss {
notFound: string;
}
}
declare const StyleModuleCssModule: StyleModuleCssModule.IStyleModuleCss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss;
};
export = StyleModuleCssModule;

View File

@ -0,0 +1,69 @@
import { FunctionalComponent, h, Fragment } from "preact";
import { Project } from "../../models/project";
import TaskTable from "./tasks-table";
import TimePreview from "./time-preview";
import FinancialPreview from "./financial-preview";
import { addTask, updateTaskEstimation, removeTask, updateProjectLabel, updateTaskLabel, ProjectReducerActions } from "../../hooks/use-project-reducer";
import { Task, TaskID, EstimationConfidence } from "../../models/task";
import RepartitionPreview from "./repartition-preview";
import { getHideFinancialPreviewOnPrint } from "../../models/params";
export interface EstimationTabProps {
project: Project
dispatch: (action: ProjectReducerActions) => void
}
const EstimationTab: FunctionalComponent<EstimationTabProps> = ({ project, dispatch }) => {
const onTaskAdd = (task: Task) => {
dispatch(addTask(task));
};
const onTaskRemove = (taskId: TaskID) => {
dispatch(removeTask(taskId));
}
const onTaskLabelUpdate = (taskId: TaskID, label: string) => {
dispatch(updateTaskLabel(taskId, label));
}
const onEstimationChange = (taskId: TaskID, confidence: EstimationConfidence, value: number) => {
dispatch(updateTaskEstimation(taskId, confidence, value));
};
return (
<Fragment>
<div class="columns">
<div class="column is-9">
<TaskTable
project={project}
onTaskAdd={onTaskAdd}
onTaskRemove={onTaskRemove}
onTaskLabelUpdate={onTaskLabelUpdate}
onEstimationChange={onEstimationChange} />
</div>
<div class="column is-3">
<TimePreview project={project} />
<RepartitionPreview project={project} />
</div>
</div>
<div class="columns">
<div class={`column ${getHideFinancialPreviewOnPrint(project) ? 'noPrint': ''}`}>
<FinancialPreview project={project} />
</div>
</div>
{
Object.keys(project.tasks).length <= 20 ?
<div class="message noPrint">
<div class="message-body">
<p><strong> Attention</strong></p>
<p>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.</p>
</div>
</div> :
null
}
<hr />
</Fragment>
);
};
export default EstimationTab;

View File

@ -0,0 +1,19 @@
import { FunctionalComponent, h, Fragment } from "preact";
import { Project } from "../../models/project";
import { useProjectEstimations, Estimation } from "../../hooks/use-project-estimations";
export interface ExportTabProps {
project: Project
}
const ExportTab: FunctionalComponent<ExportTabProps> = ({ project }) => {
return (
<div>
<label class="label is-size-4">Format JSON</label>
<pre>{ JSON.stringify(project, null, 2) }</pre>
<hr />
</div>
);
};
export default ExportTab;

View File

@ -0,0 +1,85 @@
import { FunctionalComponent, h } from "preact";
import { Project } from "../../models/project";
import { useProjectEstimations } from "../../hooks/use-project-estimations";
import { getCurrency, defaults, getTaskCategoryCost, getRoundUpEstimations } 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
}
const FinancialPreview: FunctionalComponent<FinancialPreviewProps> = ({ project }) => {
const estimations = useProjectEstimations(project);
const costs = getMinMaxCosts(project, estimations.p99);
const roundUp = getRoundUpEstimations(project);
return (
<div class="table-container">
<table class="table is-bordered is-striped is-fullwidth">
<thead>
<tr>
<th colSpan={2}>
<span>Prévisionnel financier</span><br />
<span class="is-size-7 has-text-weight-normal">confiance >= 99.7%</span>
</th>
</tr>
<tr>
<th class="is-narrow">Temps</th>
<th>Coût</th>
</tr>
</thead>
<tbody>
<tr>
<td class="is-narrow">Maximum</td>
<td>
<CostDetails project={project} cost={costs.max} roundUp={roundUp} />
</td>
</tr>
<tr>
<td class="is-narrow">Minimum</td>
<td>
<CostDetails project={project} cost={costs.min} roundUp={roundUp} />
</td>
</tr>
</tbody>
</table>
</div>
);
};
export interface CostDetailsProps {
project: Project
cost: Cost
roundUp: boolean
}
export const CostDetails:FunctionalComponent<CostDetailsProps> = ({ project, cost, roundUp }) => {
return (
<details>
<summary><strong>
{cost.totalCost} {getCurrency(project)}</strong>
<span class="is-pulled-right">{ roundUp ? Math.ceil(cost.totalTime) : cost.totalTime.toFixed(2) } <ProjectTimeUnit project={project} /></span>
</summary>
<table class={`table is-fullwidth`}>
<tbody>
{
Object.keys(cost.details).map(taskCategoryId => {
const taskCategory = project.params.taskCategories[taskCategoryId];
const details = cost.details[taskCategoryId];
return (
<tr key={`task-category-cost-${taskCategory.id}`}>
<td class={`${style.noBorder} is-size-6`}>{taskCategory.label}</td>
<td class={`${style.noBorder} is-size-6`}>{details.cost} {getCurrency(project)}</td>
<td class={`${style.noBorder} is-size-6`}>{ roundUp ? Math.ceil(details.time) : details.time.toFixed(2) } <ProjectTimeUnit project={project} /> × {getTaskCategoryCost(taskCategory)} {getCurrency(project)}</td>
</tr>
)
})
}
</tbody>
</table>
</details>
);
};
export default FinancialPreview;

View File

@ -0,0 +1,65 @@
import { FunctionalComponent, h } from "preact";
import { useEffect } from "preact/hooks";
import style from "./style.module.css";
import { newProject } from "../../models/project";
import { useProjectReducer, updateProjectLabel } from "../../hooks/use-project-reducer";
import { getProjectStorageKey } from "../../util/storage";
import { useLocalStorage } from "../../hooks/use-local-storage";
import EditableText from "../../components/editable-text";
import Tabs from "../../components/tabs";
import EstimationTab from "./estimation-tab";
import ParamsTab from "./params-tab";
import ExportTab from "./export-tab";
import { useServerSync } from "../../hooks/use-server-sync";
export interface ProjectProps {
projectId: string
}
const Project: FunctionalComponent<ProjectProps> = ({ projectId }) => {
const projectStorageKey = getProjectStorageKey(projectId);
const [ storedProject, storeProject ] = useLocalStorage(projectStorageKey, newProject(projectId));
const [ project, dispatch ] = useProjectReducer(storedProject);
useServerSync(project)
const onProjectLabelChange = (projectLabel: string) => {
dispatch(updateProjectLabel(projectLabel));
};
// Save project in local storage on change
useEffect(()=> {
storeProject(project);
}, [project]);
return (
<div class={`container ${style.estimation}`}>
<EditableText
editIconClass="is-size-4"
render={(value) => (<h2 class="is-size-3">{value}</h2>)}
onChange={onProjectLabelChange}
value={project.label ? project.label : "Projet sans nom"} />
<div class={style.tabContainer}>
<Tabs items={[
{
label: 'Estimation',
icon: '📋',
render: () => <EstimationTab project={project} dispatch={dispatch} />
},
{
label: 'Options avancées',
icon: '⚙️',
render: () => <ParamsTab project={project} dispatch={dispatch} />
},
{
label: 'Exporter',
icon: '↗️',
render: () => <ExportTab project={project} />
}
]}
/>
</div>
</div>
);
};
export default Project;

View File

@ -0,0 +1,126 @@
import { FunctionalComponent, h, Fragment } from "preact";
import { Project } from "../../models/project";
import { ProjectReducerActions, updateParam } from "../../hooks/use-project-reducer";
import { getRoundUpEstimations, getCurrency, getTimeUnit, getHideFinancialPreviewOnPrint } from "../../models/params";
import TaskCategoriesTable from "./task-categories-table";
import { useState } from "preact/hooks";
import { route } from "preact-router";
import { getProjectStorageKey } from "../../util/storage";
export interface ParamsTabProps {
project: Project
dispatch: (action: ProjectReducerActions) => void
}
const ParamsTab: FunctionalComponent<ParamsTabProps> = ({ project, dispatch }) => {
const [ deleteButtonEnabled, setDeleteButtonEnabled ] = useState(false);
const onEnableDeleteButtonChange = (evt: Event) => {
const checked = (evt.currentTarget as HTMLInputElement).checked;
setDeleteButtonEnabled(checked);
}
const onRoundUpChange = (evt: Event) => {
const checked = (evt.currentTarget as HTMLInputElement).checked;
dispatch(updateParam("roundUpEstimations", checked));
};
const onHideFinancialPreview = (evt: Event) => {
const checked = (evt.currentTarget as HTMLInputElement).checked;
dispatch(updateParam("hideFinancialPreviewOnPrint", checked));
};
const onCurrencyChange = (evt: Event) => {
const value = (evt.currentTarget as HTMLInputElement).value;
dispatch(updateParam("currency", value));
};
const timeUnit = getTimeUnit(project);
const onTimeUnitLabelChange = (evt: Event) => {
const value = (evt.currentTarget as HTMLInputElement).value;
dispatch(updateParam("timeUnit", { ...timeUnit, label: value }));
};
const onTimeUnitAcronymChange = (evt: Event) => {
const value = (evt.currentTarget as HTMLInputElement).value;
dispatch(updateParam("timeUnit", { ...timeUnit, acronym: value }));
};
const onDeleteProjectClick = (evt: Event) => {
const projectStorageKey = getProjectStorageKey(project.id);
window.localStorage.removeItem(projectStorageKey);
route('/');
};
return (
<Fragment>
<label class="label is-size-5">Impression</label>
<div class="field">
<input type="checkbox"
id="hideFinancialPreview"
name="hideFinancialPreview"
class="switch"
onChange={onHideFinancialPreview}
checked={getHideFinancialPreviewOnPrint(project)} />
<label for="hideFinancialPreview">Cacher le prévisionnel financier lors de l'impression</label>
</div>
<hr />
<div class="field">
<label class="label is-size-5">Unité de temps</label>
<div class="control">
<input class="input" type="text"
onChange={onTimeUnitLabelChange}
value={timeUnit.label} />
</div>
<label class="label is-size-6">Acronyme</label>
<div class="control">
<input class="input" type="text"
onChange={onTimeUnitAcronymChange}
value={timeUnit.acronym} />
</div>
</div>
<div class="field">
<input type="checkbox"
id="roundUpEstimations"
name="roundUpEstimations"
class="switch"
onChange={onRoundUpChange}
checked={getRoundUpEstimations(project)} />
<label for="roundUpEstimations">Arrondir les estimations de temps à l'entier supérieur</label>
</div>
<hr />
<div class="field">
<label class="label is-size-5">Devise</label>
<div class="control">
<input class="input" type="text"
onChange={onCurrencyChange}
value={getCurrency(project)} />
</div>
</div>
<hr />
<TaskCategoriesTable project={project} dispatch={dispatch} />
<hr />
<div>
<label class="label is-size-5">Supprimer le projet</label>
<div class="field">
<input type="checkbox"
id="enableDeleteButton"
name="enableDeleteButton"
class="switch is-warning"
onChange={onEnableDeleteButtonChange}
checked={deleteButtonEnabled} />
<label for="enableDeleteButton">Supprimer ce projet ?</label>
</div>
<button class="button is-danger"
onClick={onDeleteProjectClick}
disabled={!deleteButtonEnabled}>
🗑 Supprimer
</button>
</div>
<hr />
</Fragment>
);
};
export default ParamsTab;

View File

@ -0,0 +1,44 @@
import { FunctionalComponent, h } from "preact";
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, getTaskCategoriesMeanRepartition } from "../../util/stat";
export interface RepartitionPreviewProps {
project: Project
}
const RepartitionPreview: FunctionalComponent<RepartitionPreviewProps> = ({ project }) => {
const repartition = getTaskCategoriesMeanRepartition(project);
return (
<div class="table-container">
<table class="table is-bordered is-striped is-fullwidth">
<thead>
<tr>
<th colSpan={2}>Répartition moyenne</th>
</tr>
<tr>
<th>Catégorie</th>
<th>Temps (en %)</th>
</tr>
</thead>
<tbody>
{
Object.values(project.params.taskCategories).map(tc => {
let percent = (repartition[tc.id] * 100).toFixed(0);
return (
<tr key={`task-category-${tc.id}`}>
<td>{tc.label}</td>
<td>{percent} %</td>
</tr>
);
})
}
</tbody>
</table>
</div>
);
};
export default RepartitionPreview;

View File

@ -0,0 +1,24 @@
.estimation {
height: 100%;
}
.noTasks {
text-align: center !important;
font-style: italic;
}
.noBorder {
border: none !important;
}
.mainColumn {
width: 100%;
}
.middleTable td {
vertical-align: middle !important;
}
.tabContainer {
padding-top: 1em;
}

View File

@ -0,0 +1,17 @@
declare namespace StyleModuleCssModule {
export interface IStyleModuleCss {
estimation: string;
mainColumn: string;
middleTable: string;
noBorder: string;
noTasks: string;
tabContainer: string;
}
}
declare const StyleModuleCssModule: StyleModuleCssModule.IStyleModuleCss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss;
};
export = StyleModuleCssModule;

View File

@ -0,0 +1,113 @@
import { FunctionalComponent, h } from "preact";
import { Project } from "../../models/project";
import style from './style.module.css';
import { ProjectReducerActions, updateTaskCategoryCost, updateTaskCategoryLabel, removeTaskCategory, addTaskCategory } from "../../hooks/use-project-reducer";
import EditableText from "../../components/editable-text";
import { TaskCategoryID, createTaskCategory } from "../../models/task";
import { getCurrency, getTaskCategoryCost } from "../../models/params";
import { useState } from "preact/hooks";
export interface TaskCategoriesTableProps {
project: Project
dispatch: (action: ProjectReducerActions) => void
}
const TaskCategoriesTable: FunctionalComponent<TaskCategoriesTableProps> = ({ project, dispatch }) => {
const [ newTaskCategory, setNewTaskCategory ] = useState(createTaskCategory());
const onTaskCategoryRemove = (categoryId: TaskCategoryID) => {
dispatch(removeTaskCategory(categoryId));
};
const onTaskCategoryLabelChange = (categoryId: TaskCategoryID, value: string) => {
dispatch(updateTaskCategoryLabel(categoryId, value));
};
const onTaskCategoryCostChange = (categoryId: TaskCategoryID, value: string) => {
const cost = parseFloat(value);
dispatch(updateTaskCategoryCost(categoryId, cost));
};
const onNewTaskCategoryCostChange = (evt: Event) => {
const costPerTimeUnit = parseFloat((evt.currentTarget as HTMLInputElement).value);
setNewTaskCategory(newTaskCategory => ({ ...newTaskCategory, costPerTimeUnit }));
};
const onNewTaskCategoryLabelChange = (evt: Event) => {
const label = (evt.currentTarget as HTMLInputElement).value;
setNewTaskCategory(newTaskCategory => ({ ...newTaskCategory, label }));
};
const onNewTaskCategoryAddClick = (evt: Event) => {
dispatch(addTaskCategory(newTaskCategory));
setNewTaskCategory(createTaskCategory());
};
return (
<div class="table-container">
<label class="label is-size-5">Catégories de tâche</label>
<table class={`table is-bordered is-striped" ${style.middleTable}`}>
<thead>
<tr>
<th class={`${style.noBorder} is-narrow`}></th>
<th>Catégorie</th>
<th>Coût par unité de temps</th>
</tr>
</thead>
<tbody>
{
Object.values(project.params.taskCategories).map(tc => {
return (
<tr key={`task-category-${tc.id}`}>
<td>
<button
onClick={onTaskCategoryRemove.bind(null, tc.id)}
class="button is-danger is-small is-outlined">
🗑
</button>
</td>
<td>
<EditableText value={tc.label}
onChange={onTaskCategoryLabelChange.bind(null, tc.id)} />
</td>
<td>
<EditableText value={`${getTaskCategoryCost(tc)}`}
render={value=> (<span>{value} {getCurrency(project)}</span>)}
onChange={onTaskCategoryCostChange.bind(null, tc.id)} />
</td>
</tr>
);
})
}
</tbody>
<tfoot>
<tr>
<td class={`${style.noBorder}`}></td>
<td colSpan={2}>
<div class="field has-addons">
<p class="control is-expanded">
<input class="input" type="text" placeholder="Nouvelle catégorie"
value={newTaskCategory.label} onChange={onNewTaskCategoryLabelChange} />
</p>
<p class="control">
<input class="input" type="number"
value={newTaskCategory.costPerTimeUnit} onChange={onNewTaskCategoryCostChange} />
</p>
<p class="control">
<a class="button is-static">{getCurrency(project)}</a>
</p>
<p class="control">
<a class="button is-primary" onClick={onNewTaskCategoryAddClick}>
Ajouter
</a>
</p>
</div>
</td>
</tr>
</tfoot>
</table>
</div>
);
};
export default TaskCategoriesTable;

View File

@ -0,0 +1,200 @@
import { FunctionalComponent, h } from "preact";
import { useState, useEffect } from "preact/hooks";
import style from "./style.module.css";
import { Project } from "../../models/project";
import { newTask, Task, TaskID, EstimationConfidence } from "../../models/task";
import EditableText from "../../components/editable-text";
import { usePrintMediaQuery } from "../../hooks/use-media-query";
import { defaults, getTimeUnit } from "../../models/params";
import ProjectTimeUnit from "../../components/project-time-unit";
export interface TaskTableProps {
project: Project
onTaskAdd: (task: Task) => void
onTaskRemove: (taskId: TaskID) => void
onEstimationChange: (taskId: TaskID, confidence: EstimationConfidence, value: number) => void
onTaskLabelUpdate: (taskId: TaskID, label: string) => void
}
export type EstimationTotals = { [confidence in EstimationConfidence]: number }
const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, onEstimationChange, onTaskRemove, onTaskLabelUpdate }) => {
const defaultTaskCategory = Object.keys(project.params.taskCategories)[0];
const [ task, setTask ] = useState(newTask("", defaultTaskCategory));
const [ totals, setTotals ] = useState({
[EstimationConfidence.Optimistic]: 0,
[EstimationConfidence.Likely]: 0,
[EstimationConfidence.Pessimistic]: 0,
} as EstimationTotals);
const isPrint = usePrintMediaQuery();
useEffect(() => {
let optimistic = 0;
let likely = 0;
let pessimistic = 0;
Object.values(project.tasks).forEach(t => {
optimistic += t.estimations.optimistic;
likely += t.estimations.likely;
pessimistic += t.estimations.pessimistic;
});
setTotals({ optimistic, likely, pessimistic });
}, [project.tasks]);
const onNewTaskLabelChange = (evt: Event) => {
const value = (evt.currentTarget as HTMLInputElement).value;
setTask({...task, label: value});
};
const onNewTaskCategoryChange = (evt: Event) => {
const value = (evt.currentTarget as HTMLInputElement).value;
setTask({...task, category: value});
};
const onTaskLabelChange = (taskId: TaskID, value: string) => {
onTaskLabelUpdate(taskId, value);
};
const onAddTaskClick = (evt: Event) => {
onTaskAdd(task);
setTask(newTask("", defaultTaskCategory));
};
const onTaskRemoveClick = (taskId: TaskID, evt: Event) => {
onTaskRemove(taskId);
};
const withEstimationChange = (confidence: EstimationConfidence, taskID: TaskID, evt: Event) => {
const textValue = (evt.currentTarget as HTMLInputElement).value;
const value = parseFloat(textValue);
onEstimationChange(taskID, confidence, value);
};
const onOptimisticChange = withEstimationChange.bind(null, EstimationConfidence.Optimistic);
const onLikelyChange = withEstimationChange.bind(null, EstimationConfidence.Likely);
const onPessimisticChange = withEstimationChange.bind(null, EstimationConfidence.Pessimistic);
return (
<div class="table-container">
<table class={`table is-bordered is-striped is-hoverable is-fullwidth ${style.middleTable}`}>
<thead>
<tr>
<th class={`${style.noBorder} noPrint`} rowSpan={2}></th>
<th class={style.mainColumn} rowSpan={2}>Tâche</th>
<th rowSpan={2}>Catégorie</th>
<th colSpan={3}>Estimation (en <ProjectTimeUnit project={project} />)</th>
</tr>
<tr>
<th>Optimiste</th>
<th>Probable</th>
<th>Pessimiste</th>
</tr>
</thead>
<tbody>
{
Object.values(project.tasks).map(t => {
const category = project.params.taskCategories[t.category];
const categoryLabel = category ? category.label : '???';
return (
<tr key={`taks-${t.id}`}>
<td class={`is-narrow noPrint`}>
<button
onClick={onTaskRemoveClick.bind(null, t.id)}
class="button is-danger is-small is-outlined">
🗑
</button>
</td>
<td class={style.mainColumn}>
<EditableText
render={(value) => (<span>{value}</span>)}
onChange={onTaskLabelChange.bind(null, t.id)}
value={t.label} />
</td>
<td>{ categoryLabel }</td>
<td>
{
isPrint ?
<span>{t.estimations.optimistic}</span> :
<input class="input" type="number" value={t.estimations.optimistic}
min={0}
onChange={onOptimisticChange.bind(null, t.id)} />
}
</td>
<td>
{
isPrint ?
<span>{t.estimations.likely}</span> :
<input class="input" type="number" value={t.estimations.likely}
min={0}
onChange={onLikelyChange.bind(null, t.id)} />
}
</td>
<td>
{
isPrint ?
<span>{t.estimations.pessimistic}</span> :
<input class="input" type="number" value={t.estimations.pessimistic}
min={0}
onChange={onPessimisticChange.bind(null, t.id)} />
}
</td>
</tr>
)
})
}
{
Object.keys(project.tasks).length === 0 ?
<tr>
<td class={`${style.noBorder} noPrint`}></td>
<td class={style.noTasks} colSpan={5}>Aucune tâche pour l'instant.</td>
</tr> :
null
}
</tbody>
<tfoot>
<tr>
<td class={`${style.noBorder} noPrint`}></td>
<td colSpan={2} class={isPrint ? style.noBorder : ''}>
<div class="field has-addons noPrint">
<p class="control is-expanded">
<input class="input" type="text" placeholder="Nouvelle tâche"
value={task.label} onChange={onNewTaskLabelChange} />
</p>
<p class="control">
<span class="select">
<select onChange={onNewTaskCategoryChange} value={task.category}>
{
Object.values(project.params.taskCategories).map(tc => {
return (
<option key={`task-category-${tc.id}`} value={tc.id}>{tc.label}</option>
);
})
}
</select>
</span>
</p>
<p class="control">
<a class="button is-primary" onClick={onAddTaskClick}>
Ajouter
</a>
</p>
</div>
</td>
<th colSpan={3}>Total</th>
</tr>
<tr>
<td colSpan={isPrint ? 2 : 3} class={style.noBorder}></td>
<td>{totals.optimistic} <ProjectTimeUnit project={project} /></td>
<td>{totals.likely} <ProjectTimeUnit project={project} /></td>
<td>{totals.pessimistic} <ProjectTimeUnit project={project} /></td>
</tr>
</tfoot>
</table>
</div>
);
};
export default TaskTable;

View File

@ -0,0 +1,50 @@
import { FunctionalComponent, h, Fragment } from "preact";
import { Project } from "../../models/project";
import { useProjectEstimations, Estimation } from "../../hooks/use-project-estimations";
import EstimationRange from "../../components/estimation-range";
export interface TimePreviewProps {
project: Project
}
const TimePreview: FunctionalComponent<TimePreviewProps> = ({ project }) => {
const estimations = useProjectEstimations(project);
return (
<div class="table-container">
<table class="table is-bordered is-striped is-fullwidth">
<thead>
<tr>
<th colSpan={2}>Prévisionnel temps</th>
</tr>
<tr>
<th class="is-narrow">Confiance</th>
<th>Estimation</th>
</tr>
</thead>
<tbody>
<tr>
<td class="is-narrow">>= 99.7%</td>
<td><EstimationRange project={project} estimation={estimations.p99} /></td>
</tr>
<tr>
<td class="is-narrow">>= 90%</td>
<td><EstimationRange project={project} estimation={estimations.p90} /></td>
</tr>
<tr>
<td class="is-narrow">>= 68%</td>
<td><EstimationRange project={project} estimation={estimations.p68} /></td>
</tr>
</tbody>
<tfoot class="noPrint">
<tr>
<td colSpan={2}>
<a class="is-small is-pulled-right" href="https://en.wikipedia.org/wiki/Three-point_estimation" target="_blank"> Estimation à 3 points</a>
</td>
</tr>
</tfoot>
</table>
</div>
);
};
export default TimePreview;

View File

@ -0,0 +1,10 @@
#app {
display: inherit;
}
@media print
{
.noPrint, .noPrint * {
display: none !important;
}
}

13
client/src/style/index.css.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
declare namespace IndexCssModule {
export interface IIndexCss {
app: string;
noPrint: string;
}
}
declare const IndexCssModule: IndexCssModule.IIndexCss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: IndexCssModule.IIndexCss;
};
export = IndexCssModule;

91
client/src/util/stat.ts Normal file
View File

@ -0,0 +1,91 @@
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;
}
export function getTaskStandardDeviation(t: Task): number {
return (t.estimations.pessimistic - t.estimations.optimistic) / 6;
}
export function getProjectWeightedMean(p : Project): number {
return Object.values(p.tasks).reduce((sum: number, t: Task) => {
sum += getTaskWeightedMean(t);
return sum;
}, 0);
}
export function getTaskCategoryWeightedMean(taskCategoryId: TaskCategoryID, p : Project): number {
return Object.values(p.tasks).filter(t => t.category === taskCategoryId).reduce((sum: number, t: Task) => {
sum += getTaskWeightedMean(t);
return sum;
}, 0);
}
export function getProjectStandardDeviation(p : Project): number {
return Math.sqrt(Object.values(p.tasks).reduce((sum: number, t: Task) => {
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.round((estimation.e + estimation.sd) * repartition[tc.id]);
max.details[tc.id] = {
time: maxTime,
cost: Math.ceil(maxTime) * cost,
};
max.totalTime += max.details[tc.id].time;
max.totalCost += max.details[tc.id].cost;
const minTime = Math.round((estimation.e - estimation.sd) * repartition[tc.id]);
min.details[tc.id] = {
time: minTime,
cost: Math.ceil(minTime) * cost,
};
min.totalTime += min.details[tc.id].time;
min.totalCost += min.details[tc.id].cost;
});
return { max, min };
}

View File

@ -0,0 +1,8 @@
import { useState } from "preact/hooks/src";
import { ProjectID } from "../models/project";
export const ProjectStorageKeyPrefix = "project-";
export function getProjectStorageKey(id: ProjectID): string {
return `${ProjectStorageKeyPrefix}${id}`
}

53
client/src/util/uuid.ts Normal file
View File

@ -0,0 +1,53 @@
import bs58 from 'bs58';
const hex: string[] = [];
for (var i = 0; i < 256; i++) {
hex[i] = (i < 16 ? '0' : '') + (i).toString(16);
}
export function uuidV4(): string {
const r = crypto.getRandomValues(new Uint8Array(16));
r[6] = r[6] & 0x0f | 0x40;
r[8] = r[8] & 0x3f | 0x80;
return (
hex[r[0]] +
hex[r[1]] +
hex[r[2]] +
hex[r[3]] +
"-" +
hex[r[4]] +
hex[r[5]] +
"-" +
hex[r[6]] +
hex[r[7]] +
"-" +
hex[r[8]] +
hex[r[9]] +
"-" +
hex[r[10]] +
hex[r[11]] +
hex[r[12]] +
hex[r[13]] +
hex[r[14]] +
hex[r[15]]
);
}
export function toUTF8Bytes(str: string): number[] {
var utf8 = unescape(encodeURIComponent(str));
var arr: number[] = [];
for (var i = 0; i < utf8.length; i++) {
arr.push(utf8.charCodeAt(i));
}
return arr
}
export function base58UUID(): string {
const uuid = uuidV4();
return bs58.encode(toUTF8Bytes(uuid));
}