Initial commit
5
src/.babelrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"presets": [
|
||||
["preact-cli/babel", { "modules": "commonjs" }]
|
||||
]
|
||||
}
|
BIN
src/assets/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/icons/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/icons/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
src/assets/icons/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 626 B |
BIN
src/assets/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
src/assets/icons/mstile-150x150.png
Normal file
After Width: | Height: | Size: 8.8 KiB |
33
src/components/app.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { FunctionalComponent, h } from "preact";
|
||||
import { Route, Router, RouterOnChangeArgs } from "preact-router";
|
||||
|
||||
import Home from "../routes/home";
|
||||
import Project from "../routes/project";
|
||||
import NotFoundPage from '../routes/notfound';
|
||||
import Header from "./header";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((module as any).hot) {
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
require("preact/debug");
|
||||
}
|
||||
|
||||
const App: FunctionalComponent = () => {
|
||||
let currentUrl: string;
|
||||
const handleRoute = (e: RouterOnChangeArgs) => {
|
||||
currentUrl = e.url;
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="app">
|
||||
<Header />
|
||||
<Router onChange={handleRoute}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/p/:uuid" component={Project} />
|
||||
<NotFoundPage default />
|
||||
</Router>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
28
src/components/header/index.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { FunctionalComponent, h } from "preact";
|
||||
import { Link } from "preact-router/match";
|
||||
import * as style from "./style.css";
|
||||
|
||||
const Header: FunctionalComponent = () => {
|
||||
return (
|
||||
<div class="container">
|
||||
<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;
|
0
src/components/header/style.css
Normal file
3
src/components/header/style.css.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
// This file is automatically generated from your CSS. Any edits will be overwritten.
|
||||
export const header: string;
|
||||
export const active: string;
|
4
src/declaration.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
import { JSX } from "preact";
|
||||
|
||||
export = JSX;
|
||||
export as namespace JSX;
|
89
src/hooks/project-reducer.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { Project } from "../models/project";
|
||||
import { Task, TaskID, EstimationConfidence } from "../models/task";
|
||||
|
||||
export interface Action {
|
||||
type: string
|
||||
}
|
||||
|
||||
export type ProjectReducerActions =
|
||||
AddTaskAction |
|
||||
RemoveTaskAction |
|
||||
UpdateTaskEstimation
|
||||
|
||||
export function projectReducer(project: Project, action: ProjectReducerActions): Project {
|
||||
switch(action.type) {
|
||||
case ADD_TASK:
|
||||
const task = { ...(action as AddTaskAction).task };
|
||||
return {
|
||||
...project,
|
||||
tasks: {
|
||||
...project.tasks,
|
||||
[task.id]: task,
|
||||
}
|
||||
};
|
||||
case REMOVE_TASK:
|
||||
action = action as RemoveTaskAction;
|
||||
const tasks = { ...project.tasks };
|
||||
delete tasks[action.id];
|
||||
return {
|
||||
...project,
|
||||
tasks
|
||||
};
|
||||
case UPDATE_TASK_ESTIMATION:
|
||||
action = action as UpdateTaskEstimation;
|
||||
const estimations = {
|
||||
...project.tasks[action.id].estimations,
|
||||
[(action as UpdateTaskEstimation).confidence]: (action as UpdateTaskEstimation).value
|
||||
};
|
||||
if (estimations.likely <= estimations.optimistic) {
|
||||
estimations.likely = estimations.optimistic + 1;
|
||||
}
|
||||
if (estimations.pessimistic <= estimations.likely) {
|
||||
estimations.pessimistic = estimations.likely + 1;
|
||||
}
|
||||
return {
|
||||
...project,
|
||||
tasks: {
|
||||
...project.tasks,
|
||||
[action.id]: {
|
||||
...project.tasks[action.id],
|
||||
estimations: estimations,
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
export interface AddTaskAction extends Action {
|
||||
task: Task
|
||||
}
|
||||
|
||||
export const ADD_TASK = "ADD_TASK";
|
||||
|
||||
export function addTask(task: Task): AddTaskAction {
|
||||
return { type: ADD_TASK, task };
|
||||
}
|
||||
|
||||
export interface RemoveTaskAction extends Action {
|
||||
id: TaskID
|
||||
}
|
||||
|
||||
export const REMOVE_TASK = "REMOVE_TASK";
|
||||
|
||||
export function removeTask(id: TaskID): RemoveTaskAction {
|
||||
return { type: REMOVE_TASK, id };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
5
src/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
import "./style/index.scss";
|
||||
import "bulma/bulma.sass";
|
||||
import App from "./components/app.tsx";
|
||||
|
||||
export default App;
|
21
src/manifest.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "guesstimate",
|
||||
"short_name": "guesstimate",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#fff",
|
||||
"theme_color": "#673ab8",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/icons/android-chrome-192x192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "/assets/icons/android-chrome-512x512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
]
|
||||
}
|
24
src/models/params.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { TaskCategory, CategoryID } from "./task";
|
||||
|
||||
export interface TaskCategoriesIndex {
|
||||
[id: string]: TaskCategory
|
||||
}
|
||||
|
||||
export interface Params {
|
||||
taskCategories: TaskCategoriesIndex
|
||||
}
|
||||
|
||||
export const DefaultTaskCategories = {
|
||||
"7e92266f-0a7b-4728-8322-5fe05ff3b929": {
|
||||
id: "7e92266f-0a7b-4728-8322-5fe05ff3b929",
|
||||
label: "Développement"
|
||||
},
|
||||
"508a0925-a664-4426-8d40-6974156f0f00": {
|
||||
id: "508a0925-a664-4426-8d40-6974156f0f00",
|
||||
label: "Conduite de projet"
|
||||
},
|
||||
"7aab4d66-072e-4cc8-aae8-b62edd3237a8": {
|
||||
id: "7aab4d66-072e-4cc8-aae8-b62edd3237a8",
|
||||
label: "Recette"
|
||||
},
|
||||
};
|
30
src/models/project.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Project } from "./project";
|
||||
import { Task, TaskCategory, TaskID } from './task';
|
||||
import { Params, DefaultTaskCategories } 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(): Project {
|
||||
return {
|
||||
id: uuidV4(),
|
||||
label: "",
|
||||
description: "",
|
||||
tasks: {},
|
||||
params: {
|
||||
taskCategories: DefaultTaskCategories,
|
||||
},
|
||||
};
|
||||
}
|
36
src/models/task.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { uuidV4 } from "../util/uuid"
|
||||
|
||||
export type TaskID = string
|
||||
|
||||
export enum EstimationConfidence {
|
||||
Optimistic = "optimistic",
|
||||
Likely = "likely",
|
||||
Pessimistic = "pessimistic"
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: TaskID
|
||||
label: string
|
||||
category: CategoryID
|
||||
estimations: { [confidence in EstimationConfidence]: number }
|
||||
}
|
||||
|
||||
export type CategoryID = string
|
||||
|
||||
export interface TaskCategory {
|
||||
id: CategoryID
|
||||
label: string
|
||||
}
|
||||
|
||||
export function newTask(label: string, category: CategoryID): Task {
|
||||
return {
|
||||
id: uuidV4(),
|
||||
label,
|
||||
category,
|
||||
estimations: {
|
||||
[EstimationConfidence.Optimistic]: 0,
|
||||
[EstimationConfidence.Likely]: 0,
|
||||
[EstimationConfidence.Pessimistic]: 0,
|
||||
}
|
||||
};
|
||||
}
|
44
src/routes/home/index.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { FunctionalComponent, h } from "preact";
|
||||
import * as style from "./style.css";
|
||||
import { route } from 'preact-router';
|
||||
import { base58UUID } from '../../util/uuid';
|
||||
|
||||
const Home: FunctionalComponent = () => {
|
||||
|
||||
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> 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>
|
||||
<a class="panel-block">
|
||||
<span class="panel-icon">🗒️</span>
|
||||
Projet #1
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
3
src/routes/home/style.css
Normal file
@ -0,0 +1,3 @@
|
||||
.home {
|
||||
height: 100%;
|
||||
}
|
2
src/routes/home/style.css.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
// This file is automatically generated from your CSS. Any edits will be overwritten.
|
||||
export const home: string;
|
15
src/routes/notfound/index.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { FunctionalComponent, h } from "preact";
|
||||
import { Link } from 'preact-router/match';
|
||||
import * as style from "./style.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;
|
0
src/routes/notfound/style.css
Normal file
2
src/routes/notfound/style.css.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
// This file is automatically generated from your CSS. Any edits will be overwritten.
|
||||
export {};
|
37
src/routes/project/financial-preview.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { FunctionalComponent, h } from "preact";
|
||||
import * as style from "./style.css";
|
||||
import { Project } from "../../models/project";
|
||||
|
||||
export interface FinancialPreviewProps {
|
||||
project: Project
|
||||
}
|
||||
|
||||
const FinancialPreview: FunctionalComponent<FinancialPreviewProps> = ({ project }) => {
|
||||
return (
|
||||
<div class="table-container">
|
||||
<table class="table is-bordered is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2}>Prévisionnel financier</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Temps</th>
|
||||
<th>Coût</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Maximum</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Minimum</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinancialPreview;
|
61
src/routes/project/index.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { FunctionalComponent, h } from "preact";
|
||||
import { useReducer } from "preact/hooks";
|
||||
import * as style from "./style.css";
|
||||
import { newProject } from "../../models/project";
|
||||
import TaskTable from "./tasks-table";
|
||||
import TimePreview from "./time-preview";
|
||||
import FinancialPreview from "./financial-preview";
|
||||
import { projectReducer, addTask, updateTaskEstimation, removeTask } from "../../hooks/project-reducer";
|
||||
import { Task, TaskID, EstimationConfidence } from "../../models/task";
|
||||
|
||||
const Project: FunctionalComponent = () => {
|
||||
const [project, dispatch] = useReducer(projectReducer, newProject());
|
||||
|
||||
const onTaskAdd = (task: Task) => {
|
||||
dispatch(addTask(task));
|
||||
};
|
||||
|
||||
const onTaskRemove = (taskId: TaskID) => {
|
||||
dispatch(removeTask(taskId));
|
||||
}
|
||||
|
||||
const onEstimationChange = (taskId: TaskID, confidence: EstimationConfidence, value: number) => {
|
||||
dispatch(updateTaskEstimation(taskId, confidence, value));
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={`container ${style.estimation}`}>
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
<li class="is-active">
|
||||
<a>
|
||||
<span class="icon is-small">📋</span>
|
||||
Estimation
|
||||
</a>
|
||||
</li>
|
||||
{/* <li>
|
||||
<a disabled>
|
||||
<span class="icon is-small">⚙️</span>
|
||||
Paramètres
|
||||
</a>
|
||||
</li> */}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-9">
|
||||
<TaskTable
|
||||
project={project}
|
||||
onTaskAdd={onTaskAdd}
|
||||
onTaskRemove={onTaskRemove}
|
||||
onEstimationChange={onEstimationChange} />
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<TimePreview project={project} />
|
||||
<FinancialPreview project={project} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Project;
|
16
src/routes/project/style.css
Normal file
@ -0,0 +1,16 @@
|
||||
.estimation {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.noTasks {
|
||||
text-align: center !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.noBorder {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.mainColumn {
|
||||
width: 100%;
|
||||
}
|
5
src/routes/project/style.css.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
// This file is automatically generated from your CSS. Any edits will be overwritten.
|
||||
export const estimation: string;
|
||||
export const noTasks: string;
|
||||
export const noBorder: string;
|
||||
export const mainColumn: string;
|
174
src/routes/project/tasks-table.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { FunctionalComponent, h } from "preact";
|
||||
import { useState, useEffect } from "preact/hooks";
|
||||
import * as style from "./style.css";
|
||||
import { Project } from "../../models/project";
|
||||
import { newTask, Task, TaskID, EstimationConfidence } from "../../models/task";
|
||||
|
||||
|
||||
|
||||
export interface TaskTableProps {
|
||||
project: Project
|
||||
onTaskAdd: (task: Task) => void
|
||||
onTaskRemove: (taskId: TaskID) => void
|
||||
onEstimationChange: (taskId: TaskID, confidence: EstimationConfidence, value: number) => void
|
||||
}
|
||||
|
||||
export type EstimationTotals = { [confidence in EstimationConfidence]: number }
|
||||
|
||||
const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, onEstimationChange, onTaskRemove }) => {
|
||||
|
||||
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);
|
||||
|
||||
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 onTaskLabelChange = (evt: Event) => {
|
||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
||||
setTask({...task, label: value});
|
||||
};
|
||||
|
||||
const onTaskCategoryChange = (evt: Event) => {
|
||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
||||
setTask({...task, category: 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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class={style.noBorder} rowSpan={2}></th>
|
||||
<th class={style.mainColumn} rowSpan={2}>Tâche</th>
|
||||
<th rowSpan={2}>Catégorie</th>
|
||||
<th colSpan={3}>Estimation</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">
|
||||
<button
|
||||
onClick={onTaskRemoveClick.bind(null, t.id)}
|
||||
class="button is-danger is-small is-outlined">
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
<td class={style.mainColumn}>{t.label}</td>
|
||||
<td>{ categoryLabel }</td>
|
||||
<td>
|
||||
<input class="input" type="number" value={t.estimations.optimistic}
|
||||
min={0}
|
||||
onChange={onOptimisticChange.bind(null, t.id)} />
|
||||
</td>
|
||||
<td>
|
||||
<input class="input" type="number" value={t.estimations.likely}
|
||||
min={0}
|
||||
onChange={onLikelyChange.bind(null, t.id)} />
|
||||
</td>
|
||||
<td>
|
||||
<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}></td>
|
||||
<td class={style.noTasks} colSpan={5}>Aucune tâche pour l'instant.</td>
|
||||
</tr> :
|
||||
null
|
||||
}
|
||||
</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 tâche"
|
||||
value={task.label} onChange={onTaskLabelChange} />
|
||||
</p>
|
||||
<p class="control">
|
||||
<span class="select">
|
||||
<select onChange={onTaskCategoryChange} 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={3} class={style.noBorder}></td>
|
||||
<td>{totals.optimistic}</td>
|
||||
<td>{totals.likely}</td>
|
||||
<td>{totals.pessimistic}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskTable;
|
59
src/routes/project/time-preview.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { FunctionalComponent, h } from "preact";
|
||||
import { Project } from "../../models/project";
|
||||
import { Task } from "../../models/task";
|
||||
import { useState, useEffect } from "preact/hooks";
|
||||
import { getProjectWeightedMean, getProjectStandardDeviation } from "../../util/stat";
|
||||
|
||||
export interface TimePreviewProps {
|
||||
project: Project
|
||||
}
|
||||
|
||||
const TimePreview: FunctionalComponent<TimePreviewProps> = ({ project }) => {
|
||||
const [ estimations, setEstimations ] = useState({
|
||||
p99: { e: '0', sd: '0' },
|
||||
p90: { e: '0', sd: '0' },
|
||||
p68: { e: '0', sd: '0' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const projectWeightedMean = getProjectWeightedMean(project).toFixed(2);
|
||||
const projectStandardDeviation = getProjectStandardDeviation(project);
|
||||
setEstimations({
|
||||
p99: { e: projectWeightedMean, sd: (projectStandardDeviation * 3).toFixed(2) },
|
||||
p90: { e: projectWeightedMean, sd: (projectStandardDeviation * 1.645).toFixed(2) },
|
||||
p68: { e: projectWeightedMean, sd: (projectStandardDeviation).toFixed(2) },
|
||||
})
|
||||
}, [project.tasks]);
|
||||
|
||||
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>Niveau de confiance</th>
|
||||
<th>Estimation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>>= 99.7%</td>
|
||||
<td>{`${estimations.p99.e} ± ${estimations.p99.sd} j/h`}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>>= 90%</td>
|
||||
<td>{`${estimations.p90.e} ± ${estimations.p90.sd} j/h`}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>>= 68%</td>
|
||||
<td>{`${estimations.p68.e} ± ${estimations.p68.sd} j/h`}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimePreview;
|
2
src/style/index.css.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
// This file is automatically generated from your CSS. Any edits will be overwritten.
|
||||
export const app: string;
|
0
src/style/index.scss
Normal file
21
src/tests/__mocks__/browserMocks.js
Normal file
@ -0,0 +1,21 @@
|
||||
// Mock Browser API's which are not supported by JSDOM, e.g. ServiceWorker, LocalStorage
|
||||
/**
|
||||
* An example how to mock localStorage is given below 👇
|
||||
*/
|
||||
|
||||
/*
|
||||
// Mocks localStorage
|
||||
const localStorageMock = (function() {
|
||||
let store = {};
|
||||
|
||||
return {
|
||||
getItem: (key) => store[key] || null,
|
||||
setItem: (key, value) => store[key] = value.toString(),
|
||||
clear: () => store = {}
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock
|
||||
}); */
|
3
src/tests/__mocks__/fileMocks.js
Normal file
@ -0,0 +1,3 @@
|
||||
// This fixed an error related to the CSS and loading gif breaking my Jest test
|
||||
// See https://facebook.github.io/jest/docs/en/webpack.html#handling-static-assets
|
||||
export default "test-file-stub";
|
12
src/tests/header.test.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { h } from "preact";
|
||||
// See: https://github.com/mzgoddard/preact-render-spy
|
||||
import { shallow } from "preact-render-spy";
|
||||
import Header from "../components/header";
|
||||
|
||||
describe("Initial Test of the Header", () => {
|
||||
test("Header renders 3 nav items", () => {
|
||||
const context = shallow(<Header />);
|
||||
expect(context.find("h1").text()).toBe("Preact App");
|
||||
expect(context.find("Link").length).toBe(3);
|
||||
});
|
||||
});
|
24
src/util/stat.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Task } from "../models/task";
|
||||
import { Project } from "../models/project";
|
||||
|
||||
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 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));
|
||||
}
|
53
src/util/uuid.ts
Normal 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));
|
||||
}
|