Basic storage backend with diff/patch synchronization

This commit is contained in:
2020-05-03 18:34:44 +02:00
parent 1ac485abf3
commit a9c24051b0
20 changed files with 734 additions and 398 deletions

View File

@ -15,8 +15,13 @@ const EditableText: FunctionalComponent<EditableTextProps> = ({ onChange, value,
const [ editMode, setEditMode ] = useState(false);
useEffect(() => {
if (onChange) onChange(internalValue);
if (internalValue === value) return;
if (onChange) onChange(internalValue);
}, [internalValue]);
useEffect(() => {
setInternalValue(value);
}, [value])
const onEditIconClick = () => {
setEditMode(true);

View File

@ -0,0 +1,19 @@
import { useMemo, useState } from "preact/hooks";
export default function useDebounce(func: Function, delay: number) {
const [id, setId] = useState<number|null>(null)
return useMemo(
(...args) => {
if (id) {
clearTimeout(id)
} else {
setId(
window.setTimeout(() => {
setId(null)
func(...args)
}, delay)
)
}
}, [func]
)
}

View File

@ -3,33 +3,33 @@ import { useState, useEffect } from "preact/hooks";
import { getProjectWeightedMean, getProjectStandardDeviation } from "../util/stat";
export interface Estimation {
e: number
sd: number
e: number
sd: number
}
export interface ProjetEstimations {
p99: Estimation
p90: Estimation
p68: Estimation
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;
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,301 +1,325 @@
import { Project } from "../models/project";
import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from "../models/task";
import { useReducer } from "preact/hooks";
import { generate as diff } from "json-merge-patch";
import { applyPatch } from "../util/patch";
export interface Action {
type: string
type: string
}
export type ProjectReducerActions =
AddTask |
RemoveTask |
UpdateTaskEstimation |
UpdateProjectLabel |
UpdateTaskLabel |
UpdateParam |
UpdateTaskCategoryLabel |
UpdateTaskCategoryCost |
AddTaskCategory |
RemoveTaskCategory
AddTask |
RemoveTask |
UpdateTaskEstimation |
UpdateProjectLabel |
UpdateTaskLabel |
UpdateParam |
UpdateTaskCategoryLabel |
UpdateTaskCategoryCost |
AddTaskCategory |
RemoveTaskCategory |
PatchProject
export function useProjectReducer(project: Project) {
return useReducer(projectReducer, project);
return useReducer(projectReducer, project);
}
export function projectReducer(project: Project, action: ProjectReducerActions): Project {
console.log(action);
switch(action.type) {
case ADD_TASK:
return handleAddTask(project, action as AddTask);
case REMOVE_TASK:
return handleRemoveTask(project, action as RemoveTask);
case UPDATE_TASK_ESTIMATION:
return handleUpdateTaskEstimation(project, action as UpdateTaskEstimation);
case UPDATE_PROJECT_LABEL:
return handleUpdateProjectLabel(project, action as UpdateProjectLabel);
case UPDATE_TASK_LABEL:
return handleUpdateTaskLabel(project, action as UpdateTaskLabel);
case UPDATE_PARAM:
return handleUpdateParam(project, action as UpdateParam);
case ADD_TASK_CATEGORY:
return handleAddTaskCategory(project, action as AddTaskCategory);
case REMOVE_TASK_CATEGORY:
return handleRemoveTaskCategory(project, action as RemoveTaskCategory);
case UPDATE_TASK_CATEGORY_LABEL:
return handleUpdateTaskCategoryLabel(project, action as UpdateTaskCategoryLabel);
case UPDATE_TASK_CATEGORY_COST:
return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost);
}
return project;
console.log(action);
switch(action.type) {
case ADD_TASK:
return handleAddTask(project, action as AddTask);
case REMOVE_TASK:
return handleRemoveTask(project, action as RemoveTask);
case UPDATE_TASK_ESTIMATION:
return handleUpdateTaskEstimation(project, action as UpdateTaskEstimation);
case UPDATE_PROJECT_LABEL:
return handleUpdateProjectLabel(project, action as UpdateProjectLabel);
case UPDATE_TASK_LABEL:
return handleUpdateTaskLabel(project, action as UpdateTaskLabel);
case UPDATE_PARAM:
return handleUpdateParam(project, action as UpdateParam);
case ADD_TASK_CATEGORY:
return handleAddTaskCategory(project, action as AddTaskCategory);
case REMOVE_TASK_CATEGORY:
return handleRemoveTaskCategory(project, action as RemoveTaskCategory);
case UPDATE_TASK_CATEGORY_LABEL:
return handleUpdateTaskCategoryLabel(project, action as UpdateTaskCategoryLabel);
case UPDATE_TASK_CATEGORY_COST:
return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost);
case PATCH_PROJECT:
return handlePatchProject(project, action as PatchProject);
}
return project;
}
export interface AddTask extends Action {
task: Task
task: Task
}
export const ADD_TASK = "ADD_TASK";
export function addTask(task: Task): AddTask {
return { type: ADD_TASK, task };
return { type: ADD_TASK, task };
}
export function handleAddTask(project: Project, action: AddTask): Project {
const task = { ...action.task };
return {
...project,
tasks: {
...project.tasks,
[task.id]: task,
}
};
const task = { ...action.task };
return {
...project,
tasks: {
...project.tasks,
[task.id]: task,
}
};
}
export interface RemoveTask extends Action {
id: TaskID
id: TaskID
}
export const REMOVE_TASK = "REMOVE_TASK";
export function removeTask(id: TaskID): RemoveTask {
return { type: REMOVE_TASK, id };
return { type: REMOVE_TASK, id };
}
export function handleRemoveTask(project: Project, action: RemoveTask): Project {
const tasks = { ...project.tasks };
delete tasks[action.id];
return {
...project,
tasks
};
const tasks = { ...project.tasks };
delete tasks[action.id];
return {
...project,
tasks
};
}
export interface UpdateTaskEstimation extends Action {
id: TaskID
confidence: string
value: number
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 };
return { type: UPDATE_TASK_ESTIMATION, id, confidence, value };
}
export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskEstimation): Project {
const estimations = {
...project.tasks[action.id].estimations,
[action.confidence]: action.value
};
if (estimations.likely < estimations.optimistic) {
estimations.likely = estimations.optimistic;
const estimations = {
...project.tasks[action.id].estimations,
[action.confidence]: action.value
};
if (estimations.likely < estimations.optimistic) {
estimations.likely = estimations.optimistic;
}
if (estimations.pessimistic < estimations.likely) {
estimations.pessimistic = estimations.likely;
}
return {
...project,
tasks: {
...project.tasks,
[action.id]: {
...project.tasks[action.id],
estimations: estimations,
}
}
if (estimations.pessimistic < estimations.likely) {
estimations.pessimistic = estimations.likely;
}
return {
...project,
tasks: {
...project.tasks,
[action.id]: {
...project.tasks[action.id],
estimations: estimations,
}
}
};
};
}
export interface UpdateProjectLabel extends Action {
label: string
label: string
}
export const UPDATE_PROJECT_LABEL = "UPDATE_PROJECT_LABEL";
export function updateProjectLabel(label: string): UpdateProjectLabel {
return { type: UPDATE_PROJECT_LABEL, label };
return { type: UPDATE_PROJECT_LABEL, label };
}
export function handleUpdateProjectLabel(project: Project, action: UpdateProjectLabel): Project {
return {
...project,
label: action.label
};
return {
...project,
label: action.label
};
}
export interface UpdateTaskLabel extends Action {
id: TaskID
label: string
id: TaskID
label: string
}
export const UPDATE_TASK_LABEL = "UPDATE_TASK_LABEL";
export function updateTaskLabel(id: TaskID, label: string): UpdateTaskLabel {
return { type: UPDATE_TASK_LABEL, id, label };
return { type: UPDATE_TASK_LABEL, id, label };
}
export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel): Project {
return {
...project,
tasks: {
...project.tasks,
[action.id]: {
...project.tasks[action.id],
label: action.label,
}
}
};
return {
...project,
tasks: {
...project.tasks,
[action.id]: {
...project.tasks[action.id],
label: action.label,
}
}
};
}
export interface UpdateParam extends Action {
name: string
value: any
name: string
value: any
}
export const UPDATE_PARAM = "UPDATE_PARAM";
export function updateParam(name: string, value: any): UpdateParam {
return { type: UPDATE_PARAM, name, value };
return { type: UPDATE_PARAM, name, value };
}
export function handleUpdateParam(project: Project, action: UpdateParam): Project {
return {
...project,
params: {
...project.params,
[action.name]: action.value,
}
};
return {
...project,
params: {
...project.params,
[action.name]: action.value,
}
};
}
export interface UpdateTaskCategoryLabel extends Action {
categoryId: TaskCategoryID
label: string
categoryId: TaskCategoryID
label: string
}
export const UPDATE_TASK_CATEGORY_LABEL = "UPDATE_TASK_CATEGORY_LABEL";
export function updateTaskCategoryLabel(categoryId: TaskCategoryID, label: string): UpdateTaskCategoryLabel {
return { type: UPDATE_TASK_CATEGORY_LABEL, categoryId, label };
return { type: UPDATE_TASK_CATEGORY_LABEL, categoryId, label };
}
export function handleUpdateTaskCategoryLabel(project: Project, action: UpdateTaskCategoryLabel): Project {
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[action.categoryId]: {
...project.params.taskCategories[action.categoryId],
label: action.label
},
}
}
};
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[action.categoryId]: {
...project.params.taskCategories[action.categoryId],
label: action.label
},
}
}
};
}
export interface UpdateTaskCategoryCost extends Action {
categoryId: TaskCategoryID
costPerTimeUnit: number
categoryId: TaskCategoryID
costPerTimeUnit: number
}
export const UPDATE_TASK_CATEGORY_COST = "UPDATE_TASK_CATEGORY_COST";
export function updateTaskCategoryCost(categoryId: TaskCategoryID, costPerTimeUnit: number): UpdateTaskCategoryCost {
return { type: UPDATE_TASK_CATEGORY_COST, categoryId, costPerTimeUnit };
return { type: UPDATE_TASK_CATEGORY_COST, categoryId, costPerTimeUnit };
}
export function handleUpdateTaskCategoryCost(project: Project, action: UpdateTaskCategoryCost): Project {
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[action.categoryId]: {
...project.params.taskCategories[action.categoryId],
costPerTimeUnit: action.costPerTimeUnit
},
}
}
};
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[action.categoryId]: {
...project.params.taskCategories[action.categoryId],
costPerTimeUnit: action.costPerTimeUnit
},
}
}
};
}
export const ADD_TASK_CATEGORY = "ADD_TASK_CATEGORY";
export interface AddTaskCategory extends Action {
taskCategory: TaskCategory
taskCategory: TaskCategory
}
export function addTaskCategory(taskCategory: TaskCategory): AddTaskCategory {
return { type: ADD_TASK_CATEGORY, taskCategory };
return { type: ADD_TASK_CATEGORY, taskCategory };
}
export function handleAddTaskCategory(project: Project, action: AddTaskCategory): Project {
const taskCategory = { ...action.taskCategory };
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[taskCategory.id]: taskCategory,
}
}
};
const taskCategory = { ...action.taskCategory };
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[taskCategory.id]: taskCategory,
}
}
};
}
export interface RemoveTaskCategory extends Action {
taskCategoryId: TaskCategoryID
taskCategoryId: TaskCategoryID
}
export const REMOVE_TASK_CATEGORY = "REMOVE_TASK_CATEGORY";
export function removeTaskCategory(taskCategoryId: TaskCategoryID): RemoveTaskCategory {
return { type: REMOVE_TASK_CATEGORY, taskCategoryId };
return { type: REMOVE_TASK_CATEGORY, taskCategoryId };
}
export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCategory): Project {
const taskCategories = { ...project.params.taskCategories };
delete taskCategories[action.taskCategoryId];
return {
...project,
params: {
...project.params,
taskCategories
}
};
const taskCategories = { ...project.params.taskCategories };
delete taskCategories[action.taskCategoryId];
return {
...project,
params: {
...project.params,
taskCategories
}
};
}
export interface PatchProject extends Action {
from: Project
}
export const PATCH_PROJECT = "PATCH_PROJECT";
export function patchProject(from: Project): PatchProject {
return { type: PATCH_PROJECT, from };
}
export function handlePatchProject(project: Project, action: PatchProject): Project {
const p = diff(project, action.from);
console.log('patch to apply', p);
if (!p) return project;
return applyPatch(project, p) as Project;
}

View File

@ -1,57 +1,108 @@
import { Project } from "../models/project";
import { usePrevious } from "./use-previous";
import * as jsonpatch from 'fast-json-patch';
import { useEffect, useState } from "preact/hooks";
import { Operation } from "fast-json-patch";
import { generate as diff } from "json-merge-patch";
import useDebounce from "./use-debounce";
export interface ServerSyncOptions {
baseUrl: string
projectURL: string
refreshInterval: number
syncDelay: number
}
export const defaultOptions = {
baseUrl: `ws://${window.location.host}/ws`,
refreshInterval: 10000,
projectURL: `//${window.location.host}/api/v1/projects`,
syncDelay: 5000,
}
export function useServerSync(project: Project, options: ServerSyncOptions = defaultOptions) {
export function useServerSync(project: Project, applyServerUpdate: (project: Project) => void, options: ServerSyncOptions = defaultOptions) {
options = Object.assign({}, defaultOptions, options);
const [ conn, setConn ] = useState<WebSocket>(() => {
const conn = new WebSocket(`${options.baseUrl}/${project.id}`);
const [ version, setVersion ] = useState(0);
const handleAPIResponse = (res: Response) => {
// If the project does not yet exist, create it
if (res.status === 404) {
return createProject(project, options)
.then(res => res.json())
.then(result => {
setVersion(result.version);
})
;
}
// In case of conflict, notify of new server version
if (res.status === 409) {
return res.json().then((result: any) => {
applyServerUpdate(result.project);
setVersion(result.version);
});
}
// If the server version is not modified, do nothing
if (res.status === 304) {
return;
}
conn.onerror = (evt: Event) => {
console.error(evt);
};
return res.json().then((result: any) => {
applyServerUpdate(result.project);
setVersion(result.version);
});
};
conn.onopen = (evt: Event) => {
console.log('ws connection opened');
};
return conn;
});
const [ ops, setOps ] = useState<Operation[][]>([]);
useEffect(() => {
return () => {
if (!conn) return;
console.log('closing ws');
conn.close();
conn.onerror = null;
conn.onopen = null;
};
}, []);
// Force refresh periodically
useEffect(() => {
const intervalId = window.setInterval(() => {
refreshProject(project, version, options).then(handleAPIResponse);
}, options.refreshInterval);
return () => clearInterval(intervalId);
}, [version]);
useEffect(() => {
conn.send(JSON.stringify(ops));
setOps([]);
}, [ops.length > 0]);
const timeoutId =window.setTimeout(() => {
console.log('executing debounced patch');
let previousProject: Project|any = usePrevious(project);
if (!previousProject) previousProject = {};
let previousProject: Project|any = usePrevious(project);
if (!previousProject) previousProject = {};
const newOps = jsonpatch.compare(previousProject, project);
// Trigger patch if project has changed
if (ops.length === 0) return;
const patch = diff(previousProject, project);
setOps(ops => [...ops, newOps]);
}
console.log('generated patch', patch);
if (!patch) return;
patchProject(project, patch, version, options).then(handleAPIResponse);
}, options.syncDelay);
return () => clearTimeout(timeoutId);
});
}
function refreshProject(project: Project, version: number, options: ServerSyncOptions): Promise<Response> {
return fetch(`${options.projectURL}/${project.id}?version=${version}`, {
method: 'GET'
});
}
function createProject(project: Project, options: ServerSyncOptions): Promise<Response> {
return fetch(`${options.projectURL}/${project.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ project })
});
}
function patchProject(project: Project, patch: Object, version: number, options: ServerSyncOptions): Promise<any> {
return fetch(`${options.projectURL}/${project.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ version, patch })
});
};

View File

@ -2,20 +2,20 @@ import { TaskCategory, TaskCategoryID } from "./task";
import { Project } from "./project";
export interface TaskCategoriesIndex {
[id: string]: TaskCategory
[id: string]: TaskCategory
}
export interface TimeUnit {
label: string
acronym: string
label: string
acronym: string
}
export interface Params {
taskCategories: TaskCategoriesIndex
timeUnit: TimeUnit
currency: string
roundUpEstimations: boolean
hideFinancialPreviewOnPrint: boolean
taskCategories: TaskCategoriesIndex
timeUnit: TimeUnit
currency: string
roundUpEstimations: boolean
hideFinancialPreviewOnPrint: boolean
}
export const defaults = {

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import { FunctionalComponent, h } from "preact";
import { useEffect } from "preact/hooks";
import style from "./style.module.css";
import { newProject } from "../../models/project";
import { useProjectReducer, updateProjectLabel } from "../../hooks/use-project-reducer";
import { newProject, Project } from "../../models/project";
import { useProjectReducer, updateProjectLabel, patchProject } from "../../hooks/use-project-reducer";
import { getProjectStorageKey } from "../../util/storage";
import { useLocalStorage } from "../../hooks/use-local-storage";
import EditableText from "../../components/editable-text";
@ -20,7 +20,10 @@ const Project: FunctionalComponent<ProjectProps> = ({ projectId }) => {
const projectStorageKey = getProjectStorageKey(projectId);
const [ storedProject, storeProject ] = useLocalStorage(projectStorageKey, newProject(projectId));
const [ project, dispatch ] = useProjectReducer(storedProject);
useServerSync(project)
useServerSync(project, (project: Project) => {
dispatch(patchProject(project));
});
const onProjectLabelChange = (projectLabel: string) => {
dispatch(updateProjectLabel(projectLabel));

45
client/src/util/patch.ts Normal file
View File

@ -0,0 +1,45 @@
// React/Redux compatible implementation of RFC 7396
// See https://tools.ietf.org/html/rfc7396
//
// Pseudo algorithm:
//
// define MergePatch(Target, Patch):
// if Patch is an Object:
// if Target is not an Object:
// Target = {} # Ignore the contents and set it to an empty Object
// for each Name/Value pair in Patch:
// if Value is null:
// if Name exists in Target:
// remove the Name/Value pair from Target
// else:
// Target[Name] = MergePatch(Target[Name], Value)
// return Target
// else:
// return Patch
export function applyPatch(target: any, patch: any): Object {
if (!isObject(patch)) {
return patch;
}
if (!isObject(target)) {
target = {};
}
Object.keys(patch).forEach((key: any) => {
const value = patch[key];
target = { ...target };
if (value === null) {
delete target[key];
} else {
target[key] = applyPatch(target[key], value);
}
});
return target;
}
function isObject(value: any): boolean {
return value === Object(value);
}