diff --git a/client/package-lock.json b/client/package-lock.json index 5315317..5dc2be5 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -13,7 +13,7 @@ "@types/json-merge-patch": "0.0.4", "@types/react-router-dom": "^5.1.5", "bs58": "^4.0.1", - "bulma": "^0.8.2", + "bulma": "^0.9.4", "bulma-switch": "^2.0.0", "json-merge-patch": "^0.2.3", "react": "^16.13.1", @@ -1232,9 +1232,9 @@ "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=" }, "node_modules/bulma": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.8.2.tgz", - "integrity": "sha512-vMM/ijYSxX+Sm+nD7Lmc1UgWDy2JcL2nTKqwgEqXuOMU+IGALbXd5MLt/BcjBAPLIx36TtzhzBcSnOP974gcqA==" + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.4.tgz", + "integrity": "sha512-86FlT5+1GrsgKbPLRRY7cGDg8fsJiP/jzTqXXVqiUZZ2aZT8uemEOHlU1CDU+TxklPEZ11HZNNWclRBBecP4CQ==" }, "node_modules/bulma-switch": { "version": "2.0.0", @@ -9952,9 +9952,9 @@ "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=" }, "bulma": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.8.2.tgz", - "integrity": "sha512-vMM/ijYSxX+Sm+nD7Lmc1UgWDy2JcL2nTKqwgEqXuOMU+IGALbXd5MLt/BcjBAPLIx36TtzhzBcSnOP974gcqA==" + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.4.tgz", + "integrity": "sha512-86FlT5+1GrsgKbPLRRY7cGDg8fsJiP/jzTqXXVqiUZZ2aZT8uemEOHlU1CDU+TxklPEZ11HZNNWclRBBecP4CQ==" }, "bulma-switch": { "version": "2.0.0", diff --git a/client/package.json b/client/package.json index 2d7e6c0..a95a089 100644 --- a/client/package.json +++ b/client/package.json @@ -8,7 +8,7 @@ "@types/json-merge-patch": "0.0.4", "@types/react-router-dom": "^5.1.5", "bs58": "^4.0.1", - "bulma": "^0.8.2", + "bulma": "^0.9.4", "bulma-switch": "^2.0.0", "json-merge-patch": "^0.2.3", "react": "^16.13.1", diff --git a/client/src/hooks/use-project-reducer.ts b/client/src/hooks/use-project-reducer.ts index 7d9ec39..99850c2 100644 --- a/client/src/hooks/use-project-reducer.ts +++ b/client/src/hooks/use-project-reducer.ts @@ -83,7 +83,8 @@ export function handleAddTask(project: Project, action: AddTask): Project { tasks: { ...project.tasks, [task.id]: task, - } + }, + updatedAt: new Date(), }; } @@ -102,7 +103,8 @@ export function handleRemoveTask(project: Project, action: RemoveTask): Project delete tasks[action.id]; return { ...project, - tasks + tasks, + updatedAt: new Date(), }; } @@ -131,8 +133,9 @@ export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskE [action.id]: { ...project.tasks[action.id], estimations: estimations, - } - } + }, + }, + updatedAt: new Date(), }; } @@ -150,7 +153,8 @@ export function updateProjectLabel(label: string): UpdateProjectLabel { export function handleUpdateProjectLabel(project: Project, action: UpdateProjectLabel): Project { return { ...project, - label: action.label + label: action.label, + updatedAt: new Date(), }; } @@ -174,7 +178,8 @@ export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel) ...project.tasks[action.id], label: action.label, } - } + }, + updatedAt: new Date(), }; } @@ -195,7 +200,8 @@ export function handleUpdateParam(project: Project, action: UpdateParam): Projec params: { ...project.params, [action.name]: action.value, - } + }, + updatedAt: new Date(), }; } @@ -222,7 +228,8 @@ export function handleUpdateTaskCategoryLabel(project: Project, action: UpdateTa label: action.label }, } - } + }, + updatedAt: new Date(), }; } @@ -249,7 +256,8 @@ export function handleUpdateTaskCategoryCost(project: Project, action: UpdateTas costPerTimeUnit: action.costPerTimeUnit }, } - } + }, + updatedAt: new Date(), }; } @@ -273,7 +281,8 @@ export function handleAddTaskCategory(project: Project, action: AddTaskCategory) ...project.params.taskCategories, [taskCategory.id]: taskCategory, } - } + }, + updatedAt: new Date(), }; } @@ -295,7 +304,8 @@ export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCat params: { ...project.params, taskCategories - } + }, + updatedAt: new Date(), }; } @@ -313,5 +323,8 @@ export function handlePatchProject(project: Project, action: PatchProject): Proj const p = diff(project, action.from); console.log('patch to apply', p); if (!p) return project; - return applyPatch(project, p) as Project; + const patched: Project = applyPatch(project, p) as Project + if (typeof patched.createdAt === 'string') patched.createdAt = new Date(patched.createdAt) + if (typeof patched.updatedAt === 'string') patched.updatedAt = new Date(patched.updatedAt) + return patched; } \ No newline at end of file diff --git a/client/src/hooks/useSort.tsx b/client/src/hooks/useSort.tsx new file mode 100644 index 0000000..dad6af6 --- /dev/null +++ b/client/src/hooks/useSort.tsx @@ -0,0 +1,23 @@ +import { useCallback, useEffect, useState } from "react"; + +export enum Direction { + ASC = 1, + DESC = -1 +} + +export function useSort(items: T[], key: string, direction: Direction = Direction.ASC): T[] { + const [ sorted, setSorted ] = useState(items); + useEffect(() => { + const sorted = [ ...items ] + + sorted.sort((a: any, b: any) => { + if (a[key] > b[key]) return direction; + if (a[key] < b[key]) return -direction; + return 0 + }) + + setSorted(sorted) + }, [key, items, direction]) + + return sorted +} \ No newline at end of file diff --git a/client/src/models/project.ts b/client/src/models/project.ts index 87f8978..6bba944 100644 --- a/client/src/models/project.ts +++ b/client/src/models/project.ts @@ -1,4 +1,4 @@ -import { Task, TaskCategory, TaskID } from './task'; +import { Task } from './task'; import { Params, defaults } from "./params"; import { uuidV4 } from "../util/uuid"; @@ -10,6 +10,8 @@ export interface Project { description: string tasks: Tasks params: Params + createdAt: Date + updatedAt: Date } export interface Tasks { @@ -25,5 +27,7 @@ export function newProject(id?: string): Project { params: { ...defaults }, + createdAt: new Date(), + updatedAt: new Date(), }; } \ No newline at end of file diff --git a/client/src/routes/home/index.tsx b/client/src/routes/home/index.tsx index 4a462f2..225956f 100644 --- a/client/src/routes/home/index.tsx +++ b/client/src/routes/home/index.tsx @@ -1,58 +1,109 @@ -import React, { FunctionComponent } from "react"; +import React, { FunctionComponent, MouseEvent, useCallback, useState } from "react"; import style from "./style.module.css"; import { useHistory } from 'react-router'; import { base58UUID } from '../../util/uuid'; import { useStoredProjectList } from "../../hooks/use-stored-project-list"; +import { formatDate } from "../../util/date"; +import { Direction, useSort } from "../../hooks/useSort"; const Home: FunctionComponent = () => { - const [ projects, refreshProjects ] = useStoredProjectList(); - const history = useHistory(); + const [projects] = useStoredProjectList() + const [sortingKey, setSortingKey] = useState('updatedAt') + const [sortingDirection, setSortingDirection] = useState(Direction.DESC) + const sortedProjects = useSort(projects, sortingKey, sortingDirection) + const history = useHistory() - const openNewProject = () => { - const uuid = base58UUID(); - history.push(`/p/${uuid}`); - }; + const openNewProject = () => { + const uuid = base58UUID() + history.push(`/p/${uuid}`) + }; - return ( -
-
-
-
- -
-
-

- Mes projets -

- {/*
-

- - 🔍 -

-
*/} - { - projects.map(p => ( - - 🗒️ - { p.label ? p.label : "Projet sans nom" } - - )) - } - { - projects.length === 0 ? -

-

Aucun project pour l'instant.
-

: - null - } -
-
+ const openProject = useCallback((evt: MouseEvent) => { + const projectId = evt.currentTarget.dataset.projectId; + history.push(`/p/${projectId}`) + }, []) + + const sortBy = useCallback((evt: MouseEvent) => { + const key = evt.currentTarget.dataset.sortKey + if (!key) return + + if (sortingKey !== key) { + setSortingKey(key) + return + } + + setSortingDirection(sortingDirection => -sortingDirection) + + }, [sortingKey, sortingDirection]) + + return ( +
+
+
+
+ +
+
+
+ + + + + + + + + { + sortedProjects.map(p => ( + + + + + )) + } + { + projects.length === 0 ? + + + : + null + } + +
+ 🗒️ + {p.label ? p.label : "Projet sans nom"} + + {p.updatedAt ? formatDate(p.updatedAt) : "--"} +
+ Aucun project pour l'instant. +
+
- ); +
+
+ ); }; +interface TableHeaderProps { + onClick?: React.MouseEventHandler + sortingKey: string + isCurrentSortingKey: boolean + sortingDirection: Direction + label: string +} + +const TableHeader: FunctionComponent = ({ onClick, isCurrentSortingKey, sortingKey, label, sortingDirection }) => { + return ( + {label}{sortingDirection === Direction.ASC ? '↑' : '↓'} + ) +} + export default Home; diff --git a/client/src/util/date.ts b/client/src/util/date.ts new file mode 100644 index 0000000..4e91121 --- /dev/null +++ b/client/src/util/date.ts @@ -0,0 +1,20 @@ +export function asDate (d: Date|string): Date { + if (typeof d === 'string') return new Date(d) + if (d instanceof Date) return d + + throw new Error(`Unexpected date value '${JSON.stringify(d)}' !`) +} + +const intl = Intl.DateTimeFormat(navigator.language, { + weekday: 'long', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric' +}) + +export function formatDate (d: Date|string): string { + d = asDate(d) + return intl.format(d) +} \ No newline at end of file diff --git a/server/internal/model/project.go b/server/internal/model/project.go index e9a1ff7..502a9d7 100644 --- a/server/internal/model/project.go +++ b/server/internal/model/project.go @@ -1,5 +1,7 @@ package model +import "time" + type ProjectID string type ProjectEntry struct { @@ -14,6 +16,8 @@ type Project struct { Description string `json:"description"` Tasks map[TaskID]Task `json:"tasks"` Params Params `json:"params"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type Params struct {