Basic storage backend with diff/patch synchronization
This commit is contained in:
@ -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);
|
||||
|
19
client/src/hooks/use-debounce.tsx
Normal file
19
client/src/hooks/use-debounce.tsx
Normal 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]
|
||||
)
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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 })
|
||||
});
|
||||
};
|
||||
|
@ -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 = {
|
||||
|
@ -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
|
||||
},
|
||||
};
|
||||
}
|
@ -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 {
|
||||
|
@ -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
45
client/src/util/patch.ts
Normal 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);
|
||||
}
|
Reference in New Issue
Block a user