Better print style + base tabs

This commit is contained in:
wpetit 2020-04-21 14:10:50 +02:00
parent 6e0ccd5575
commit 642b555b3d
17 changed files with 287 additions and 107 deletions

View File

@ -20,7 +20,7 @@ const App: FunctionalComponent = () => {
return ( return (
<div id="app"> <div id="app">
<Header /> <Header class="noPrint" />
<Router onChange={handleRoute}> <Router onChange={handleRoute}>
<Route path="/" component={Home} /> <Route path="/" component={Home} />
<Route path="/p/:projectId" component={Project} /> <Route path="/p/:projectId" component={Project} />

View File

@ -2,9 +2,13 @@ import { FunctionalComponent, h } from "preact";
import { Link } from "preact-router/match"; import { Link } from "preact-router/match";
import * as style from "./style.css"; import * as style from "./style.css";
const Header: FunctionalComponent = () => { export interface HeaderProps {
class?: string
}
const Header: FunctionalComponent<HeaderProps> = ({ ...props}) => {
return ( return (
<div class="container"> <div class={`container ${props.class ? props.class : ''}`}>
<div class="columns"> <div class="columns">
<div class="column"> <div class="column">
<nav class="navbar" role="navigation" aria-label="main navigation"> <nav class="navbar" role="navigation" aria-label="main navigation">

View File

@ -0,0 +1,50 @@
import { FunctionalComponent, h, ComponentChild } from "preact";
import * as style from "./style.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%;
}

3
src/components/tabs/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 tabs: string;
export const tabContent: string;

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,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

@ -1,5 +1,4 @@
import "./style/index.css"; import "./style/index.css";
import "bulma/css/bulma.css"; import "bulma/css/bulma.css";
import App from "./components/app.tsx"; import App from "./components/app.tsx";

View File

@ -0,0 +1,50 @@
import { FunctionalComponent, h } 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";
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 (
<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} />
<FinancialPreview project={project} />
</div>
</div>
);
};
export default EstimationTab;

View File

@ -1,12 +1,17 @@
import { FunctionalComponent, h } from "preact"; import { FunctionalComponent, h } from "preact";
import * as style from "./style.css"; import * as style from "./style.css";
import { Project } from "../../models/project"; import { Project } from "../../models/project";
import { useProjectEstimations } from "../../hooks/use-project-estimations";
export interface FinancialPreviewProps { export interface FinancialPreviewProps {
project: Project project: Project
} }
const FinancialPreview: FunctionalComponent<FinancialPreviewProps> = ({ project }) => { const FinancialPreview: FunctionalComponent<FinancialPreviewProps> = ({ project }) => {
const estimations = useProjectEstimations(project);
const costPerTimeUnit = 500;
const maxCost = Math.ceil((estimations.p99.e + estimations.p99.sd) * costPerTimeUnit);
const minCost = Math.ceil((estimations.p99.e - estimations.p99.sd) * costPerTimeUnit);
return ( return (
<div class="table-container"> <div class="table-container">
<table class="table is-bordered is-striped is-fullwidth"> <table class="table is-bordered is-striped is-fullwidth">
@ -15,18 +20,18 @@ const FinancialPreview: FunctionalComponent<FinancialPreviewProps> = ({ project
<th colSpan={2}>Prévisionnel financier</th> <th colSpan={2}>Prévisionnel financier</th>
</tr> </tr>
<tr> <tr>
<th>Temps</th> <th class="is-narrow">Temps</th>
<th>Coût</th> <th>Coût</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>Maximum</td> <td class="is-narrow">Maximum</td>
<td></td> <td>~ {maxCost} </td>
</tr> </tr>
<tr> <tr>
<td>Minimum</td> <td class="is-narrow">Minimum</td>
<td></td> <td>~ {minCost} </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -2,14 +2,12 @@ import { FunctionalComponent, h } from "preact";
import { useEffect } from "preact/hooks"; import { useEffect } from "preact/hooks";
import * as style from "./style.css"; import * as style from "./style.css";
import { newProject } from "../../models/project"; import { newProject } from "../../models/project";
import TaskTable from "./tasks-table"; import { useProjectReducer, updateProjectLabel } from "../../hooks/use-project-reducer";
import TimePreview from "./time-preview";
import FinancialPreview from "./financial-preview";
import { useProjectReducer, addTask, updateTaskEstimation, removeTask, updateProjectLabel, updateTaskLabel } from "../../hooks/use-project-reducer";
import { Task, TaskID, EstimationConfidence } from "../../models/task";
import { getProjectStorageKey } from "../../util/storage"; import { getProjectStorageKey } from "../../util/storage";
import { useLocalStorage } from "../../hooks/use-local-storage"; import { useLocalStorage } from "../../hooks/use-local-storage";
import EditableText from "../../components/editable-text"; import EditableText from "../../components/editable-text";
import Tabs from "../../components/tabs";
import EstimationTab from "./estimation-tab";
export interface ProjectProps { export interface ProjectProps {
projectId: string projectId: string
@ -20,22 +18,6 @@ const Project: FunctionalComponent<ProjectProps> = ({ projectId }) => {
const [ storedProject, storeProject ] = useLocalStorage(projectStorageKey, newProject(projectId)); const [ storedProject, storeProject ] = useLocalStorage(projectStorageKey, newProject(projectId));
const [ project, dispatch ] = useProjectReducer(storedProject); const [ project, dispatch ] = useProjectReducer(storedProject);
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));
};
const onProjectLabelChange = (projectLabel: string) => { const onProjectLabelChange = (projectLabel: string) => {
dispatch(updateProjectLabel(projectLabel)); dispatch(updateProjectLabel(projectLabel));
}; };
@ -49,38 +31,28 @@ const Project: FunctionalComponent<ProjectProps> = ({ projectId }) => {
<div class={`container ${style.estimation}`}> <div class={`container ${style.estimation}`}>
<EditableText <EditableText
editIconClass="is-size-4" editIconClass="is-size-4"
render={(value) => (<h3 class="is-size-3">{value}</h3>)} render={(value) => (<h2 class="is-size-3">{value}</h2>)}
onChange={onProjectLabelChange} onChange={onProjectLabelChange}
value={project.label ? project.label : "Projet sans nom"} /> value={project.label ? project.label : "Projet sans nom"} />
<div class="tabs"> <div class={style.tabContainer}>
<ul> <Tabs items={[
<li class="is-active"> {
<a> label: 'Estimation',
<span class="icon is-small">📋</span> icon: '📋',
Estimation render: () => <EstimationTab project={project} dispatch={dispatch} />
</a> },
</li> {
{/* <li> label: 'Paramètres',
<a disabled> icon: '⚙️',
<span class="icon is-small"></span> render: () => null
Paramètres },
</a> {
</li> */} label: 'Exporter',
</ul> icon: '↗️',
</div> render: () => null
<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} />
<FinancialPreview project={project} />
</div>
</div> </div>
</div> </div>
); );

View File

@ -14,3 +14,11 @@
.mainColumn { .mainColumn {
width: 100%; width: 100%;
} }
.middleTable td {
vertical-align: middle;
}
.tabContainer {
padding-top: 1em;
}

View File

@ -3,3 +3,5 @@ export const estimation: string;
export const noTasks: string; export const noTasks: string;
export const noBorder: string; export const noBorder: string;
export const mainColumn: string; export const mainColumn: string;
export const middleTable: string;
export const tabContainer: string;

View File

@ -4,8 +4,7 @@ import * as style from "./style.css";
import { Project } from "../../models/project"; import { Project } from "../../models/project";
import { newTask, Task, TaskID, EstimationConfidence } from "../../models/task"; import { newTask, Task, TaskID, EstimationConfidence } from "../../models/task";
import EditableText from "../../components/editable-text"; import EditableText from "../../components/editable-text";
import { usePrintMediaQuery } from "../../hooks/use-media-query";
export interface TaskTableProps { export interface TaskTableProps {
project: Project project: Project
@ -27,6 +26,8 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
[EstimationConfidence.Pessimistic]: 0, [EstimationConfidence.Pessimistic]: 0,
} as EstimationTotals); } as EstimationTotals);
const isPrint = usePrintMediaQuery();
useEffect(() => { useEffect(() => {
let optimistic = 0; let optimistic = 0;
let likely = 0; let likely = 0;
@ -76,10 +77,10 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
return ( return (
<div class="table-container"> <div class="table-container">
<table class="table is-bordered is-striped is-hoverable is-fullwidth"> <table class={`table is-bordered is-striped is-hoverable is-fullwidth ${style.middleTable}`}>
<thead> <thead>
<tr> <tr>
<th class={style.noBorder} rowSpan={2}></th> <th class={`${style.noBorder} noPrint`} rowSpan={2}></th>
<th class={style.mainColumn} rowSpan={2}>Tâche</th> <th class={style.mainColumn} rowSpan={2}>Tâche</th>
<th rowSpan={2}>Catégorie</th> <th rowSpan={2}>Catégorie</th>
<th colSpan={3}>Estimation</th> <th colSpan={3}>Estimation</th>
@ -97,7 +98,7 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
const categoryLabel = category ? category.label : '???'; const categoryLabel = category ? category.label : '???';
return ( return (
<tr key={`taks-${t.id}`}> <tr key={`taks-${t.id}`}>
<td class="is-narrow"> <td class={`is-narrow noPrint`}>
<button <button
onClick={onTaskRemoveClick.bind(null, t.id)} onClick={onTaskRemoveClick.bind(null, t.id)}
class="button is-danger is-small is-outlined"> class="button is-danger is-small is-outlined">
@ -112,19 +113,31 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
</td> </td>
<td>{ categoryLabel }</td> <td>{ categoryLabel }</td>
<td> <td>
<input class="input" type="number" value={t.estimations.optimistic} {
min={0} isPrint ?
onChange={onOptimisticChange.bind(null, t.id)} /> <span>{t.estimations.optimistic}</span> :
<input class="input" type="number" value={t.estimations.optimistic}
min={0}
onChange={onOptimisticChange.bind(null, t.id)} />
}
</td> </td>
<td> <td>
<input class="input" type="number" value={t.estimations.likely} {
min={0} isPrint ?
onChange={onLikelyChange.bind(null, t.id)} /> <span>{t.estimations.likely}</span> :
<input class="input" type="number" value={t.estimations.likely}
min={0}
onChange={onLikelyChange.bind(null, t.id)} />
}
</td> </td>
<td> <td>
<input class="input" type="number" value={t.estimations.pessimistic} {
min={0} isPrint ?
onChange={onPessimisticChange.bind(null, t.id)} /> <span>{t.estimations.pessimistic}</span> :
<input class="input" type="number" value={t.estimations.pessimistic}
min={0}
onChange={onPessimisticChange.bind(null, t.id)} />
}
</td> </td>
</tr> </tr>
) )
@ -133,7 +146,7 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
{ {
Object.keys(project.tasks).length === 0 ? Object.keys(project.tasks).length === 0 ?
<tr> <tr>
<td class={style.noBorder}></td> <td class={`${style.noBorder} noPrint`}></td>
<td class={style.noTasks} colSpan={5}>Aucune tâche pour l'instant.</td> <td class={style.noTasks} colSpan={5}>Aucune tâche pour l'instant.</td>
</tr> : </tr> :
null null
@ -141,9 +154,9 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td class={style.noBorder}></td> <td class={`${style.noBorder} noPrint`}></td>
<td colSpan={2}> <td colSpan={2} class={isPrint ? style.noBorder : ''}>
<div class="field has-addons"> <div class="field has-addons noPrint">
<p class="control is-expanded"> <p class="control is-expanded">
<input class="input" type="text" placeholder="Nouvelle tâche" <input class="input" type="text" placeholder="Nouvelle tâche"
value={task.label} onChange={onNewTaskLabelChange} /> value={task.label} onChange={onNewTaskLabelChange} />
@ -171,7 +184,7 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
<th colSpan={3}>Total</th> <th colSpan={3}>Total</th>
</tr> </tr>
<tr> <tr>
<td colSpan={3} class={style.noBorder}></td> <td colSpan={isPrint ? 2 : 3} class={style.noBorder}></td>
<td>{totals.optimistic}</td> <td>{totals.optimistic}</td>
<td>{totals.likely}</td> <td>{totals.likely}</td>
<td>{totals.pessimistic}</td> <td>{totals.pessimistic}</td>

View File

@ -1,29 +1,13 @@
import { FunctionalComponent, h } from "preact"; import { FunctionalComponent, h } from "preact";
import { Project } from "../../models/project"; import { Project } from "../../models/project";
import { Task } from "../../models/task"; import { useProjectEstimations } from "../../hooks/use-project-estimations";
import { useState, useEffect } from "preact/hooks";
import { getProjectWeightedMean, getProjectStandardDeviation } from "../../util/stat";
export interface TimePreviewProps { export interface TimePreviewProps {
project: Project project: Project
} }
const TimePreview: FunctionalComponent<TimePreviewProps> = ({ project }) => { const TimePreview: FunctionalComponent<TimePreviewProps> = ({ project }) => {
const [ estimations, setEstimations ] = useState({ const estimations = useProjectEstimations(project);
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 ( return (
<div class="table-container"> <div class="table-container">
@ -33,25 +17,25 @@ const TimePreview: FunctionalComponent<TimePreviewProps> = ({ project }) => {
<th colSpan={2}>Prévisionnel temps</th> <th colSpan={2}>Prévisionnel temps</th>
</tr> </tr>
<tr> <tr>
<th>Niveau de confiance</th> <th class="is-narrow">Confiance</th>
<th>Estimation</th> <th>Estimation</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>>= 99.7%</td> <td class="is-narrow">>= 99.7%</td>
<td>{`${estimations.p99.e} ± ${estimations.p99.sd} j/h`}</td> <td>{`${estimations.p99.e.toPrecision(2)} ± ${estimations.p99.sd.toPrecision(2)} j/h`}</td>
</tr> </tr>
<tr> <tr>
<td>>= 90%</td> <td class="is-narrow">>= 90%</td>
<td>{`${estimations.p90.e} ± ${estimations.p90.sd} j/h`}</td> <td>{`${estimations.p90.e.toPrecision(2)} ± ${estimations.p90.sd.toPrecision(2)} j/h`}</td>
</tr> </tr>
<tr> <tr>
<td>>= 68%</td> <td class="is-narrow">>= 68%</td>
<td>{`${estimations.p68.e} ± ${estimations.p68.sd} j/h`}</td> <td>{`${estimations.p68.e.toPrecision(2)} ± ${estimations.p68.sd.toPrecision(2)} j/h`}</td>
</tr> </tr>
</tbody> </tbody>
<tfoot> <tfoot class="noPrint">
<tr> <tr>
<td colSpan={2}> <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> <a class="is-small is-pulled-right" href="https://en.wikipedia.org/wiki/Three-point_estimation" target="_blank"> Estimation à 3 points</a>

View File

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

View File

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