Initial commit

This commit is contained in:
2020-04-20 11:14:46 +02:00
commit 364840c665
48 changed files with 20852 additions and 0 deletions

5
src/.babelrc Normal file
View File

@ -0,0 +1,5 @@
{
"presets": [
["preact-cli/babel", { "modules": "commonjs" }]
]
}

BIN
src/assets/favicon.ico Normal file

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

33
src/components/app.tsx Normal file
View 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;

View 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;

View File

3
src/components/header/style.css.d.ts vendored Normal file
View 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
View File

@ -0,0 +1,4 @@
import { JSX } from "preact";
export = JSX;
export as namespace JSX;

View 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
View 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
View 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
View 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
View 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
View 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
View 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>&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>
<a class="panel-block">
<span class="panel-icon">🗒</span>
Projet #1
</a>
</div>
</div>
</div>
</div>
);
};
export default Home;

View File

@ -0,0 +1,3 @@
.home {
height: 100%;
}

2
src/routes/home/style.css.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
// This file is automatically generated from your CSS. Any edits will be overwritten.
export const home: string;

View 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;

View File

2
src/routes/notfound/style.css.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
// This file is automatically generated from your CSS. Any edits will be overwritten.
export {};

View 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;

View 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;

View 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
View 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;

View 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;

View 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
View 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
View File

View 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
}); */

View 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
View 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
View 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
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));
}