Initial commit
|
@ -0,0 +1,36 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
browser: true
|
||||
},
|
||||
extends: [
|
||||
"plugin:react/recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier/@typescript-eslint",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true
|
||||
},
|
||||
ecmaVersion: 2018,
|
||||
sourceType: "module",
|
||||
},
|
||||
rules: {
|
||||
"react/no-unknown-property": ["error", { ignore: ["class"] }],
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
pragma: "h",
|
||||
version: "detect"
|
||||
},
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["*.js"],
|
||||
rules: {
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
/build
|
||||
/*.log
|
|
@ -0,0 +1,4 @@
|
|||
package.json
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
build
|
|
@ -0,0 +1 @@
|
|||
tabWidth: 2
|
|
@ -0,0 +1,19 @@
|
|||
# guesstimate
|
||||
|
||||
## CLI Commands
|
||||
* `npm install`: Installs dependencies
|
||||
|
||||
* `npm run start`: Runs `serve` or `dev`, depending on `NODE_ENV` value. Defaults to `dev server`
|
||||
|
||||
* `npm run dev`: Run a development, HMR server
|
||||
|
||||
* `npm run serve`: Run a production-like server
|
||||
|
||||
* `npm run build`: Production-ready build
|
||||
|
||||
* `npm run lint`: Pass TypeScript files using TSLint
|
||||
|
||||
* `npm run test`: Run Jest and [`preact-render-spy`](https://github.com/mzgoddard/preact-render-spy) for your tests
|
||||
|
||||
|
||||
For detailed explanation on how things work, checkout the [CLI Readme](https://github.com/developit/preact-cli/blob/master/README.md).
|
|
@ -0,0 +1,33 @@
|
|||
module.exports = {
|
||||
transform: {
|
||||
"^.+\\.tsx?$": "ts-jest"
|
||||
},
|
||||
verbose: true,
|
||||
setupFiles: [
|
||||
"<rootDir>/src/tests/__mocks__/browserMocks.js"
|
||||
],
|
||||
testURL: "http://localhost:8080",
|
||||
moduleFileExtensions: [
|
||||
"js",
|
||||
"jsx",
|
||||
"ts",
|
||||
"tsx"
|
||||
],
|
||||
moduleDirectories: [
|
||||
"node_modules"
|
||||
],
|
||||
testMatch: [
|
||||
"**/__tests__/**/*.[jt]s?(x)",
|
||||
"**/?(*.)(spec|test).[jt]s?(x)"
|
||||
],
|
||||
moduleNameMapper: {
|
||||
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/src/tests/__mocks__/fileMock.js",
|
||||
"\\.(css|less|scss)$": "identity-obj-proxy",
|
||||
"^./style$": "identity-obj-proxy",
|
||||
"^preact$": "<rootDir>/node_modules/preact/dist/preact.min.js",
|
||||
"^react$": "preact-compat",
|
||||
"^react-dom$": "preact-compat",
|
||||
"^create-react-class$": "preact-compat/lib/create-react-class",
|
||||
"^react-addons-css-transition-group$": "preact-css-transition-group"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"private": true,
|
||||
"name": "guesstimate",
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "preact build",
|
||||
"serve": "sirv build --port 8080 --cors --single",
|
||||
"dev": "preact watch",
|
||||
"lint": "eslint src/**/*.{js,jsx,ts,tsx}",
|
||||
"test": "jest ./tests"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{css,md,scss}": "prettier --write",
|
||||
"*.{js,jsx,ts,tsx}": "eslint --fix"
|
||||
},
|
||||
"eslintIgnore": [
|
||||
"build/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@types/bs58": "^4.0.1",
|
||||
"bs58": "^4.0.1",
|
||||
"bulma": "^0.8.2",
|
||||
"preact": "^10.3.1",
|
||||
"preact-jsx-chai": "^3.0.0",
|
||||
"preact-markup": "^2.0.0",
|
||||
"preact-render-to-string": "^5.1.4",
|
||||
"preact-router": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^25.1.2",
|
||||
"@types/webpack-env": "^1.15.1",
|
||||
"@typescript-eslint/eslint-plugin": "^2.19.0",
|
||||
"@typescript-eslint/parser": "^2.19.0",
|
||||
"css-loader": "^1.0.1",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-prettier": "^6.10.0",
|
||||
"eslint-plugin-prettier": "^3.1.2",
|
||||
"eslint-plugin-react": "^7.18.3",
|
||||
"husky": "^4.2.1",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^25.1.0",
|
||||
"lint-staged": "^10.0.7",
|
||||
"node-sass": "^4.13.1",
|
||||
"preact-cli": "^3.0.0-next.19",
|
||||
"preact-render-spy": "^1.3.0",
|
||||
"prettier": "^1.19.1",
|
||||
"sass-loader": "^8.0.2",
|
||||
"sirv-cli": "^1.0.0-next.3",
|
||||
"ts-jest": "^25.2.0",
|
||||
"ts-loader": "^6.2.1",
|
||||
"typescript": "^3.7.5",
|
||||
"typings-for-css-modules-loader": "^1.7.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { resolve } from "path";
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Function that mutates the original webpack config.
|
||||
* Supports asynchronous changes when a promise is returned (or it's an async function).
|
||||
*
|
||||
* @param {object} config - original webpack config.
|
||||
* @param {object} env - options passed to the CLI.
|
||||
* @param {WebpackConfigHelpers} helpers - object with useful helpers for working with the webpack config.
|
||||
* @param {object} options - this is mainly relevant for plugins (will always be empty in the config), default to an empty object
|
||||
**/
|
||||
webpack(config, env, helpers, options) {
|
||||
// Switch css-loader for typings-for-css-modules-loader, which is a wrapper
|
||||
// that automatically generates .d.ts files for loaded CSS
|
||||
helpers.getLoadersByName(config, "css-loader").forEach(({ loader }) => {
|
||||
loader.loader = "typings-for-css-modules-loader";
|
||||
loader.options = Object.assign(loader.options, {
|
||||
camelCase: true,
|
||||
banner:
|
||||
"// This file is automatically generated from your CSS. Any edits will be overwritten.",
|
||||
namedExport: true,
|
||||
silent: true
|
||||
});
|
||||
});
|
||||
|
||||
// Use any `index` file, not just index.js
|
||||
config.resolve.alias["preact-cli-entrypoint"] = resolve(
|
||||
process.cwd(),
|
||||
"src",
|
||||
"index"
|
||||
);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"presets": [
|
||||
["preact-cli/babel", { "modules": "commonjs" }]
|
||||
]
|
||||
}
|
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 50 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 626 B |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 8.8 KiB |
|
@ -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;
|
|
@ -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,0 +1,3 @@
|
|||
// This file is automatically generated from your CSS. Any edits will be overwritten.
|
||||
export const header: string;
|
||||
export const active: string;
|
|
@ -0,0 +1,4 @@
|
|||
import { JSX } from "preact";
|
||||
|
||||
export = JSX;
|
||||
export as namespace JSX;
|
|
@ -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 };
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import "./style/index.scss";
|
||||
import "bulma/bulma.sass";
|
||||
import App from "./components/app.tsx";
|
||||
|
||||
export default App;
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,3 @@
|
|||
.home {
|
||||
height: 100%;
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
// This file is automatically generated from your CSS. Any edits will be overwritten.
|
||||
export const home: string;
|
|
@ -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,0 +1,2 @@
|
|||
// This file is automatically generated from your CSS. Any edits will be overwritten.
|
||||
export {};
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,16 @@
|
|||
.estimation {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.noTasks {
|
||||
text-align: center !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.noBorder {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.mainColumn {
|
||||
width: 100%;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
// This file is automatically generated from your CSS. Any edits will be overwritten.
|
||||
export const app: string;
|
|
@ -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
|
||||
}); */
|
|
@ -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";
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
}
|
|
@ -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));
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Basic Options */
|
||||
"target": "ES5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */
|
||||
"module": "ESNext", /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
|
||||
// "lib": [], /* Specify library files to be included in the compilation: */
|
||||
"allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
"jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
"jsxFactory": "h", /* Specify the JSX factory function to use when targeting react JSX emit, e.g. React.createElement or h. */
|
||||
// "declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
// "sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
// "outDir": "./", /* Redirect output structure to the directory. */
|
||||
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
// "noEmit": true, /* Do not emit outputs. */
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
// "noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
|
||||
/* Module Resolution Options */
|
||||
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
|
||||
/* Source Map Options */
|
||||
// "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||
// "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||
|
||||
/* Experimental Options */
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*.tsx", "src/**/*.ts"]
|
||||
}
|