diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1de3eb1 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +SENTRY_DSN='https://XXXX.ingest.sentry.io/00000000' diff --git a/.gitignore b/.gitignore index dd6c4e3..abdd811 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ npm-debug.log.* *.css.d.ts *.sass.d.ts *.scss.d.ts + +.env diff --git a/assets/icon.ico b/assets/icon.ico index 98948ea..66503e1 100644 Binary files a/assets/icon.ico and b/assets/icon.ico differ diff --git a/assets/icon.png b/assets/icon.png old mode 100755 new mode 100644 index 755a6e5..76f2432 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/package.json b/package.json index 4f88d01..4e55d49 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,11 @@ "build:renderer": "cross-env NODE_ENV=production webpack --config ./.erb/configs/webpack.config.renderer.prod.babel.js", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir src", "lint": "cross-env NODE_ENV=development eslint . --cache --ext .js,.jsx,.ts,.tsx", - "package": "ECHO 'Backup profile before install' && electron-builder build --publish never", + "package": "npm run build && electron-builder build --publish never", "postinstall": "node -r @babel/register .erb/scripts/CheckNativeDep.js && electron-builder install-app-deps && yarn cross-env NODE_ENV=development webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.babel.js && opencollective-postinstall && yarn-deduplicate yarn.lock", "start": "node -r @babel/register ./.erb/scripts/CheckPortInUse.js && cross-env yarn start:renderer", "start:main": "cross-env NODE_ENV=development electron -r ./.erb/scripts/BabelRegister ./src/main.dev.ts", - "start:renderer": "cross-env NODE_ENV=development webpack serve --config ./.erb/configs/webpack.config.renderer.dev.babel.js", - "test": "jest" + "start:renderer": "cross-env NODE_ENV=development webpack serve --config ./.erb/configs/webpack.config.renderer.dev.babel.js" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ @@ -112,27 +111,6 @@ "hot", "reload" ], - "jest": { - "testURL": "http://localhost/", - "moduleNameMapper": { - "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/.erb/mocks/fileMock.js", - "\\.(css|less|sass|scss)$": "identity-obj-proxy" - }, - "moduleFileExtensions": [ - "js", - "jsx", - "ts", - "tsx", - "json" - ], - "moduleDirectories": [ - "node_modules", - "src/node_modules" - ], - "setupFiles": [ - "./.erb/scripts/CheckBuildsExist.js" - ] - }, "devDependencies": { "@babel/core": "^7.12.9", "@babel/plugin-proposal-class-properties": "^7.12.1", @@ -159,22 +137,15 @@ "@babel/register": "^7.12.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", "@teamsupercell/typings-for-css-modules-loader": "^2.4.0", - "@testing-library/jest-dom": "^5.11.6", - "@testing-library/react": "^11.2.2", - "@types/enzyme": "^3.10.5", - "@types/enzyme-adapter-react-16": "^1.0.6", "@types/history": "4.7.6", - "@types/jest": "^26.0.15", "@types/node": "14.14.10", "@types/react": "^16.9.44", "@types/react-dom": "^16.9.9", "@types/react-router-dom": "^5.1.6", - "@types/react-test-renderer": "^16.9.3", "@types/webpack-env": "^1.15.2", "@typescript-eslint/eslint-plugin": "^4.8.1", "@typescript-eslint/parser": "^4.8.1", "babel-eslint": "^10.1.0", - "babel-jest": "^26.1.0", "babel-loader": "^8.2.2", "babel-plugin-dev-expression": "^0.2.2", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", @@ -184,16 +155,13 @@ "core-js": "^3.6.5", "cross-env": "^7.0.2", "css-loader": "^5.0.1", - "css-minimizer-webpack-plugin": "^1.1.5", + "css-minimizer-webpack-plugin": "^2.0.0", "detect-port": "^1.3.0", "electron": "^12.0.2", - "electron-builder": "^22.3.6", + "electron-builder": "^22.11.1", "electron-devtools-installer": "git+https://github.com/MarshallOfSound/electron-devtools-installer.git", "electron-notarize": "^1.0.0", "electron-rebuild": "^2.3.2", - "enzyme": "^3.11.0", - "enzyme-adapter-react-16": "^1.15.3", - "enzyme-to-json": "^3.5.0", "eslint": "^7.5.0", "eslint-config-airbnb": "^18.2.0", "eslint-config-airbnb-typescript": "^12.0.0", @@ -202,7 +170,6 @@ "eslint-import-resolver-webpack": "^0.13.0", "eslint-plugin-compat": "^3.8.0", "eslint-plugin-import": "^2.22.0", - "eslint-plugin-jest": "^24.1.3", "eslint-plugin-jsx-a11y": "6.4.1", "eslint-plugin-prettier": "^3.1.4", "eslint-plugin-promise": "^4.2.1", @@ -210,15 +177,12 @@ "eslint-plugin-react-hooks": "^4.0.8", "file-loader": "^6.0.0", "husky": "^4.2.5", - "identity-obj-proxy": "^3.0.0", - "jest": "^26.1.0", "lint-staged": "^10.2.11", "mini-css-extract-plugin": "^1.3.1", "node-sass": "^5.0.0", "opencollective-postinstall": "^2.0.3", "prettier": "^2.0.5", "react-refresh": "^0.9.0", - "react-test-renderer": "^17.0.1", "rimraf": "^3.0.0", "sass-loader": "^10.1.0", "style-loader": "^2.0.0", @@ -235,10 +199,12 @@ "dependencies": { "@ant-design/colors": "6.0.0", "@ant-design/icons": "4.6.2", + "@sentry/electron": "2.5.0", "antd": "4.15.0", "caniuse-lite": "1.0.30001214", "clsx": "^1.1.1", "date-fns": "2.20.1", + "dotenv": "10.0.0", "electron-debug": "^3.1.0", "electron-log": "^4.2.4", "electron-updater": "^4.3.4", @@ -246,6 +212,7 @@ "history": "^5.0.0", "less": "4.1.1", "less-loader": "8.0.0", + "material-icons": "0.7.4", "mobx": "6.1.8", "mobx-react": "7.1.0", "moment": "2.29.1", diff --git a/src/App.global.less b/src/App.global.less index 64fe464..63c517a 100644 --- a/src/App.global.less +++ b/src/App.global.less @@ -1,3 +1,11 @@ +@font-face { + font-family: "Material Icons"; + src: url("~material-icons/iconfont/material-icons.woff2") format("woff2"), + url("~material-icons/iconfont/material-icons.woff2") format("woff"); +} + +@import '~material-icons/css/material-icons.min.css'; + #root { display: flex; flex: 1; diff --git a/src/base/TreeModelStoreHelper.ts b/src/base/TreeModelStoreHelper.ts index abd4270..fe8c3a4 100644 --- a/src/base/TreeModelStoreHelper.ts +++ b/src/base/TreeModelStoreHelper.ts @@ -20,6 +20,17 @@ export default abstract class TreeModelStoreHelper { } static getFlatItemsRecursive>( + tree: T[], + condition: (task: T) => boolean + ): T[] { + const result: T[] = []; + + this.getFlatItemsRecursiveBase(tree, condition, result); + + return result; + } + + static getFlatItemsRecursiveBase>( tasks: T[], condition: (task: T) => boolean, result: T[] @@ -29,7 +40,7 @@ export default abstract class TreeModelStoreHelper { result.push(task); } if (Array.isArray(task.children)) { - this.getFlatItemsRecursive(task.children, condition, result); + this.getFlatItemsRecursiveBase(task.children, condition, result); } } return result; diff --git a/src/base/repositories/AbstractFileRepository.ts b/src/base/repositories/AbstractFileRepository.ts index 7d63e15..45846d9 100644 --- a/src/base/repositories/AbstractFileRepository.ts +++ b/src/base/repositories/AbstractFileRepository.ts @@ -1,6 +1,8 @@ const fs = require('fs'); const path = require('path'); +import FsHelper from '../../helpers/FsHelper'; + const APP_FOLDER = 'YadroTimeTracker'; const PROFILE_FOLDER = 'profile1'; @@ -16,7 +18,7 @@ export default abstract class AbstractFileRepository { return path.join( AbstractFileRepository.appDataFolder, APP_FOLDER, - PROFILE_FOLDER + PROFILE_FOLDER, ); } @@ -27,22 +29,14 @@ export default abstract class AbstractFileRepository { public restore(defaultValue: T): T { if (fs.existsSync(this.filePath)) { const data = fs.readFileSync(this.filePath); + // TODO handle parse error. Backup file with issues and return defaultValue return JSON.parse(data); } return defaultValue; } public save(data: T) { - [ - path.join(AbstractFileRepository.appDataFolder, APP_FOLDER), - AbstractFileRepository.profileFolder, - ].forEach((p) => AbstractFileRepository.createFolderIfNotExists(p)); - fs.writeFileSync(this.filePath, JSON.stringify(data), 'utf-8'); - } - - private static createFolderIfNotExists(path: string) { - if (!fs.existsSync(path)) { - fs.mkdirSync(path); - } + FsHelper.mkdirIfNotExists(AbstractFileRepository.profileFolder); + return FsHelper.writeFile(this.filePath, data); } } diff --git a/src/components/PlayStopButton/PlayStopButton.tsx b/src/components/PlayStopButton/PlayStopButton.tsx index cf61e7f..7be9459 100644 --- a/src/components/PlayStopButton/PlayStopButton.tsx +++ b/src/components/PlayStopButton/PlayStopButton.tsx @@ -5,7 +5,7 @@ import clsx from 'clsx'; import { createUseStyles } from 'react-jss'; import CircleButton from '../CircleButton/CircleButton'; -import rootStore from '../../services/RootStore'; +import rootStore from '../../modules/RootStore'; import TaskModel from '../../models/TaskModel'; const { tasksStore } = rootStore; diff --git a/src/screens/hours/components/SelectDate/SelectDate.tsx b/src/components/SelectDate/SelectDate.tsx similarity index 100% rename from src/screens/hours/components/SelectDate/SelectDate.tsx rename to src/components/SelectDate/SelectDate.tsx diff --git a/src/components/TaskControl/TaskControl.tsx b/src/components/TaskControl/TaskControl.tsx index 0831d70..a44357e 100644 --- a/src/components/TaskControl/TaskControl.tsx +++ b/src/components/TaskControl/TaskControl.tsx @@ -4,15 +4,15 @@ import { PauseOutlined } from '@ant-design/icons'; import './TaskControl.less'; -import rootStore from '../../services/RootStore'; -import { useTaskDuration } from '../../hooks/TaskHooks'; +import rootStore from '../../modules/RootStore'; +import * as TaskHooks from '../../hooks/TaskHooks'; import CircleButton from '../CircleButton/CircleButton'; const { tasksStore, projectStore } = rootStore; export default observer(function TaskControl() { const task = tasksStore.activeTask; - const duration = useTaskDuration(task); + const duration = TaskHooks.useTaskDuration(task); const project = useMemo(() => { return projectStore.get(task?.projectId || ''); diff --git a/src/components/TimeRangeModal/TimeRangeModal.tsx b/src/components/TimeRangeModal/TimeRangeModal.tsx index e32ddde..7636607 100644 --- a/src/components/TimeRangeModal/TimeRangeModal.tsx +++ b/src/components/TimeRangeModal/TimeRangeModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Button, Col, Form, Input, Modal, Row, TimePicker } from 'antd'; import { Moment } from 'moment/moment'; import moment from 'moment'; @@ -6,7 +6,7 @@ import { DeleteFilled } from '@ant-design/icons'; import { observer } from 'mobx-react'; import isBefore from 'date-fns/isBefore'; -import rootStore from '../../services/RootStore'; +import rootStore from '../../modules/RootStore'; import TaskTimeItemModel from '../../models/TaskTimeItemModel'; import { ITimeRangeModel } from '../../models/TaskModel'; import { Undefined } from '../../types/CommonTypes'; @@ -25,116 +25,128 @@ interface TimeRangeModalProps { onClose: () => void; } -export default observer(function TimeRangeModal({ - taskTime, - visible, - onClose, -}: TimeRangeModalProps) { - const [valid, setValid] = useState(false); - const [description, setDescription] = useState(''); - const [timeRange, setTimeRange] = useState>(); - const timeInProgress = !taskTime?.time.end; +const TimeRangeModal = observer( + ({ taskTime, visible, onClose }: TimeRangeModalProps) => { + const [valid, setValid] = useState(false); + const [description, setDescription] = useState(''); + const [timeRange, setTimeRange] = useState>(); + const timeInProgress = !taskTime?.time.end; - useEffect(() => { - setValid( - !!timeRange?.start || - !!( - timeRange?.start && - timeRange?.end && - isBefore(timeRange?.start, timeRange?.end) - ) - ); - }, [timeRange]); + const handleOk = useCallback(() => { + if (taskTime?.task && timeRange?.start) { + const { task, index } = taskTime; + timeRange.description = description; + tasksStore.setTime(task, index, timeRange); + } + onClose(); + }, [description, onClose, taskTime, timeRange]); - useEffect(() => { - if (taskTime) { - setTimeRange({ ...taskTime.time }); - setDescription(taskTime.time.description || ''); - } - }, [taskTime]); + useEffect(() => { + function keyupHandler(e: KeyboardEvent) { + // Hotkey: Ctrl+Enter + if (e.ctrlKey && e.key === 'Enter') { + handleOk(); + } + } - function handleOk() { - if (taskTime?.task && timeRange?.start) { - const { task, index } = taskTime; - if (description) { - timeRange.description = description; + document.addEventListener('keyup', keyupHandler); + return () => { + document.removeEventListener('keyup', keyupHandler); + }; + }, [handleOk]); + + useEffect(() => { + setValid( + !!timeRange?.start || + !!( + timeRange?.start && + timeRange?.end && + isBefore(timeRange?.start, timeRange?.end) + ) + ); + }, [timeRange]); + + useEffect(() => { + if (taskTime) { + setTimeRange({ ...taskTime.time }); + setDescription(taskTime.time.description || ''); } - tasksStore.setTime(task, index, timeRange); - } - onClose(); - } + }, [taskTime]); - function handleDelete() { - if (taskTime) { - tasksStore.deleteTime(taskTime.task, taskTime.index); + function handleDelete() { + if (taskTime) { + tasksStore.deleteTime(taskTime.task, taskTime.index); + } + onClose(); } - onClose(); - } - function handleCancel() { - onClose(); - } + function handleCancel() { + onClose(); + } - function onChange(field: RangeField) { - return (value: Moment | null) => { - const newTimeRange = { - ...timeRange, - [field]: value?.toDate() || undefined, + function onChange(field: RangeField) { + return (value: Moment | null) => { + const newTimeRange = { + ...timeRange, + [field]: value?.toDate() || undefined, + }; + setTimeRange(newTimeRange as ITimeRangeModel); }; - setTimeRange(newTimeRange as ITimeRangeModel); - }; + } + + return ( + +
+ +
{taskTime?.task.title}
+
+ + setDescription(e.target.value)} + /> + + + + + + + + + + + + + + + + + + + +
+
+ ); } +); - return ( - -
- -
{taskTime?.task.title}
-
- - setDescription(e.target.value)} - /> - - - - - - - - - - - - - - - - - - - -
-
- ); -}); +export default TimeRangeModal; diff --git a/src/components/TimeRangeModal/components/TimeRangeDuration.tsx b/src/components/TimeRangeModal/components/TimeRangeDuration.tsx index f71f71b..ecfa57c 100644 --- a/src/components/TimeRangeModal/components/TimeRangeDuration.tsx +++ b/src/components/TimeRangeModal/components/TimeRangeDuration.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { useTimeRangeDuration } from '../../../hooks/TaskHooks'; +import * as TaskHooks from '../../../hooks/TaskHooks'; import { ITimeRangeModel } from '../../../models/TaskModel'; interface TimeRangeDurationProps { @@ -10,7 +10,7 @@ interface TimeRangeDurationProps { export default function TimeRangeDuration({ timeRange, }: TimeRangeDurationProps) { - const duration = useTimeRangeDuration(timeRange); + const duration = TaskHooks.useTimeRangeDuration(timeRange); return
{duration}
; } diff --git a/src/helpers/ArrayHelper.ts b/src/helpers/ArrayHelper.ts new file mode 100644 index 0000000..6b660fc --- /dev/null +++ b/src/helpers/ArrayHelper.ts @@ -0,0 +1,46 @@ +type CallbackPrev = (prev: T | undefined, cur: T, index: number) => R; + +export function iterPrevCurrent( + items: T[], + callback: CallbackPrev +) { + for (let i = 0; i < items.length; i++) { + if (i === 0) { + callback(undefined, items[i], i); + } else { + callback(items[i - 1], items[i], i); + } + } +} + +export function mapPrevCurrent( + items: T[], + callback: CallbackPrev +): R[] { + const result: R[] = []; + for (let i = 0; i < items.length; i++) { + if (i === 0) { + result.push(callback(undefined, items[i], i)); + } else { + result.push(callback(items[i - 1], items[i], i)); + } + } + return result; +} + +type CallbackNext = (cur: T, next: T | undefined, index: number) => R; + +export function mapCurrentNext( + items: T[], + callback: CallbackNext +): R[] { + const result: R[] = []; + for (let i = 0; i < items.length; i++) { + if (i === items.length - 1) { + result.push(callback(items[i], undefined, i)); + } else { + result.push(callback(items[i], items[i + 1], i)); + } + } + return result; +} diff --git a/src/helpers/DateTime.ts b/src/helpers/DateTime.ts index 280956f..4e8aaf2 100644 --- a/src/helpers/DateTime.ts +++ b/src/helpers/DateTime.ts @@ -1,17 +1,40 @@ import { ITimeRangeModel } from '../models/TaskModel'; +import { format } from 'date-fns'; +import { iterPrevCurrent } from './ArrayHelper'; function timePad(time: number): string { return String(time).padStart(2, '0'); } -function onlySecs(secs: number) { - return `${secs}s`; +function onlySecs(sign: string, secs: number) { + return `${sign}${secs}s`; +} + +function timeItemsToString( + sign: string, + hrs: number, + mins: number, + secs: number, + showSeconds: boolean +) { + if (hrs === 0 && mins === 0) { + return onlySecs(sign, secs); + } + + let result = `${sign}${timePad(hrs)}:${timePad(mins)}`; + if (showSeconds) { + result += `:${timePad(secs)}`; + } + + return result; } export function msToTime(s: number, showSeconds: boolean = true) { if (!s) { return '0s'; } + const sign = s < 0 ? '-' : ''; + s = Math.abs(s); const ms = s % 1000; s = (s - ms) / 1000; const secs = s % 60; @@ -19,26 +42,54 @@ export function msToTime(s: number, showSeconds: boolean = true) { const mins = s % 60; const hrs = (s - mins) / 60; - if (showSeconds) { - if (hrs === 0 && mins === 0) { - return onlySecs(secs); - } - return `${timePad(hrs)}:${timePad(mins)}:${timePad(secs)}`; - } - if (hrs === 0 && mins === 0) { - return onlySecs(secs); - } - return `${timePad(hrs)}:${timePad(mins)}`; + return timeItemsToString(sign, hrs, mins, secs, showSeconds); } -export function calcDuration(taskTime: ITimeRangeModel[]) { +export function calcDuration(taskTime: ITimeRangeModel[]): number { return taskTime.reduce((prev, timeRange) => { if (!timeRange.start) { return 0; } - if (timeRange.end) { - return prev + timeRange.end.getTime() - timeRange.start.getTime(); + if (!timeRange.end) { + return prev + new Date().getTime() - timeRange.start.getTime(); } - return prev + new Date().getTime() - timeRange.start.getTime(); + return prev + timeRange.end.getTime() - timeRange.start.getTime(); }, 0); } + +export function calcDurationGaps(taskTime: ITimeRangeModel[]): number { + let result = 0; + iterPrevCurrent(taskTime, (prev, cur) => { + if (prev?.end) { + result += cur.start.getTime() - prev.end.getTime(); + } + }); + + const lastTask = taskTime[taskTime.length - 1]; + if (lastTask && lastTask.end) { + result += new Date().getTime() - lastTask.end.getTime(); + } + + return result; +} + +const TIME_FORMAT = 'HH:mm'; +const NO_TIME = '--:--'; + +export function getTime(date: Date | undefined) { + if (!date) { + return NO_TIME; + } + return format(date, TIME_FORMAT); +} + +export const EIGHT_HOURS = 8 * 60 * 60 * 1000; + +export function estimateWorkingTimeEnd( + startDate: Date | undefined, + restTimeMs: number +): Date | undefined { + return startDate + ? new Date(startDate.getTime() + restTimeMs + EIGHT_HOURS) + : undefined; +} diff --git a/src/helpers/FsHelper.ts b/src/helpers/FsHelper.ts new file mode 100644 index 0000000..cf23602 --- /dev/null +++ b/src/helpers/FsHelper.ts @@ -0,0 +1,22 @@ +const fs = require('fs'); + +const FsHelper = { + writeFile(path: string, data: any) { + return new Promise((resolve, reject) => { + fs.writeFile(path, JSON.stringify(data), 'utf-8', (err: NodeJS.ErrnoException | null) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }) + }, + mkdirIfNotExists(path: string) { + if (!fs.existsSync(path)) { + fs.mkdirSync(path, { recursive: true }); + } + }, +}; + +export default FsHelper; diff --git a/src/helpers/IterateLastCurrent.ts b/src/helpers/IterateLastCurrent.ts deleted file mode 100644 index 8ccb110..0000000 --- a/src/helpers/IterateLastCurrent.ts +++ /dev/null @@ -1,16 +0,0 @@ -type Callback = (last: T | undefined, cur: T, index: number) => R; - -export function mapPrevCurrent( - items: T[], - callback: Callback -): R[] { - const result: R[] = []; - for (let i = 0; i < items.length; i++) { - if (i === 0) { - result.push(callback(undefined, items[i], i)); - } else { - result.push(callback(items[i - 1], items[i], i)); - } - } - return result; -} diff --git a/src/services/TaskTimeItem.ts b/src/helpers/TaskHelper.ts similarity index 58% rename from src/services/TaskTimeItem.ts rename to src/helpers/TaskHelper.ts index 1aecf6a..599e4dc 100644 --- a/src/services/TaskTimeItem.ts +++ b/src/helpers/TaskHelper.ts @@ -3,8 +3,21 @@ import isSameDay from 'date-fns/isSameDay'; import TaskModel from '../models/TaskModel'; import TaskTimeItemModel from '../models/TaskTimeItemModel'; import compareAsc from 'date-fns/compareAsc'; +import TaskWithDurationModel from '../models/TaskWithDurationModel'; -export default function getTimeItems( +/** + * Returns TaskTimeItemModel contains time range + * return { + * task, + * time: { + * start: Date, + * end: Date, + * description + * }, + * index, + * } + */ +export function getTimeItems( tasks: TaskModel[], date: Date ): TaskTimeItemModel[] { @@ -25,3 +38,17 @@ export default function getTimeItems( taskTime = taskTime.sort((a, b) => compareAsc(a.time.start, b.time.start)); return taskTime; } + +/** + * Return tasks with total time for selected day + * @param tasks + * @param date + */ +export function getTasksWithTotalTimeForDay( + tasks: TaskModel[], + date: Date +): TaskWithDurationModel[] { + return tasks.map( + (task) => new TaskWithDurationModel(task, task.getDurationByDate(date)) + ); +} diff --git a/src/hooks/TaskHooks.ts b/src/hooks/TaskHooks.ts index f6b693a..8ac86c1 100644 --- a/src/hooks/TaskHooks.ts +++ b/src/hooks/TaskHooks.ts @@ -1,12 +1,11 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { isBefore } from 'date-fns'; -import { calcDuration, msToTime } from '../helpers/DateTime'; +import { calcDuration, calcDurationGaps, msToTime } from '../helpers/DateTime'; import TaskModel, { ITimeRangeModel } from '../models/TaskModel'; import TaskTimeItemModel from '../models/TaskTimeItemModel'; export function useTaskDuration(model: TaskModel | undefined) { - model = model || ({} as TaskModel); - const intervalRef = useRef(); const [duration, setDuration] = useState(''); @@ -28,53 +27,70 @@ export function useTaskDuration(model: TaskModel | undefined) { clearInterval(intervalRef.current); } }; - }, [model, model.active]); + }, [model, model?.active]); return duration; } -export function useTimeItemsDuration( - taskTime: TaskTimeItemModel[], - showSeconds: boolean = false -) { - const [duration, setDuration] = useState(''); +export function useTimeItemsDuration(taskTime: TaskTimeItemModel[]) { + const [durationMs, setDurationMs] = useState(0); + const [restMs, setRestMs] = useState(0); const intervalRef = useRef(); + const calcTaskDuration = useCallback( + () => calcDuration(taskTime.map((t) => t.time)), + [taskTime] + ); + + const calcTaskGapsDuration = useCallback( + () => calcDurationGaps(taskTime.map((t) => t.time)), + [taskTime] + ); + + const setTimes = useCallback(() => { + setDurationMs(calcTaskDuration()); + setRestMs(calcTaskGapsDuration()); + }, [calcTaskDuration, calcTaskGapsDuration]); + useEffect(() => { - const haveActiveTime = taskTime.some((t) => !t.time.end); - setDuration( - msToTime(calcDuration(taskTime.map((t) => t.time)), showSeconds) - ); - if (haveActiveTime) { - intervalRef.current = setInterval(() => { - setDuration( - msToTime(calcDuration(taskTime.map((t) => t.time)), showSeconds) - ); - }, 1000); - } + setTimes(); + + intervalRef.current = setInterval(() => { + setTimes(); + }, 1000); + return () => { if (intervalRef.current) { clearInterval(intervalRef.current); } }; - }, [taskTime]); + }, [setTimes, taskTime]); - return duration; + return { + durationMs, + restMs, + }; } export function useTimeRangeDuration(timeRange: ITimeRangeModel | undefined) { const [duration, setDuration] = useState(''); const intervalRef = useRef(); + const calcTimeRangeDuration = useCallback( + () => msToTime(timeRange ? calcDuration([timeRange]) : 0), + [timeRange] + ); + useEffect(() => { if (!timeRange) { return; } + setDuration(calcTimeRangeDuration()); + const haveActiveTime = !timeRange.end; - setDuration(msToTime(calcDuration([timeRange]))); if (haveActiveTime) { intervalRef.current = setInterval(() => { - setDuration(msToTime(calcDuration([timeRange]))); + setDuration(calcTimeRangeDuration()); }, 1000); } return () => { @@ -82,7 +98,21 @@ export function useTimeRangeDuration(timeRange: ITimeRangeModel | undefined) { clearInterval(intervalRef.current); } }; - }, [timeRange]); + }, [calcTimeRangeDuration, timeRange]); return duration; } + +export function useStartWorkingTime( + timeItems: TaskTimeItemModel[] +): Date | undefined { + return useMemo(() => { + let minTime: Date | undefined; + timeItems.forEach((time) => { + if (!minTime || isBefore(time.time.start, minTime)) { + minTime = time.time.start; + } + }); + return minTime; + }, [timeItems]); +} diff --git a/src/index.tsx b/src/index.tsx index 6432a27..a76d431 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,10 @@ import React from 'react'; import { render } from 'react-dom'; +require('dotenv').config(); + import App from './App'; +import { initSentry } from './shared/initSentry'; + +initSentry(); render(, document.getElementById('root')); diff --git a/src/main.dev.ts b/src/main.dev.ts index 945243a..4587fab 100644 --- a/src/main.dev.ts +++ b/src/main.dev.ts @@ -10,12 +10,17 @@ */ import 'core-js/stable'; import 'regenerator-runtime/runtime'; +require('dotenv').config(); import path from 'path'; import { app, BrowserWindow, shell } from 'electron'; import { autoUpdater } from 'electron-updater'; import log from 'electron-log'; import Badge from 'electron-windows-badge'; + import MenuBuilder from './menu'; +import { initSentry } from './shared/initSentry'; + +initSentry(); console.log('Working path:', app.getAppPath()); diff --git a/src/main.prod.js.LICENSE.txt b/src/main.prod.js.LICENSE.txt index 15036cd..6916c8f 100644 --- a/src/main.prod.js.LICENSE.txt +++ b/src/main.prod.js.LICENSE.txt @@ -1 +1,23 @@ +/*! + * cookie + * Copyright(c) 2012-2014 Roman Shtylman + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ + /*! http://mths.be/fromcodepoint v0.1.0 by @mathias */ diff --git a/src/models/TaskModel.ts b/src/models/TaskModel.ts index 143e1e0..3f00683 100644 --- a/src/models/TaskModel.ts +++ b/src/models/TaskModel.ts @@ -3,6 +3,7 @@ import isSameDay from 'date-fns/isSameDay'; import AbstractModel from '../base/AbstractModel'; import { ITreeItem } from '../types/ITreeItem'; +import { startOfDay } from 'date-fns'; export interface IJsonTimeRangeModel { start: string; @@ -11,7 +12,7 @@ export interface IJsonTimeRangeModel { } export interface ITimeRangeModel { - start?: Date; + start: Date; end?: Date; description?: string; } @@ -36,7 +37,6 @@ export default class TaskModel extends AbstractModel { time: ITimeRangeModel[] = []; datesInProgress: Date[] = []; details: string = ''; - deleted: boolean = false; constructor(props: IJsonTaskModel) { super(); @@ -82,10 +82,21 @@ export default class TaskModel extends AbstractModel { } get duration() { - return this.time.reduce((prev: number, range: ITimeRangeModel) => { + return this.time.reduce((acc: number, range: ITimeRangeModel) => { const { start, end } = range; const duration = (end ? end.getTime() : Date.now()) - start.getTime(); - return prev + duration; + return acc + duration; + }, 0); + } + + getDurationByDate(date: Date) { + return this.time.reduce((acc: number, range: ITimeRangeModel) => { + const { start, end } = range; + let duration = 0; + if (isSameDay(start, date)) { + duration = (end ? end.getTime() : Date.now()) - start.getTime(); + } + return acc + duration; }, 0); } @@ -101,10 +112,6 @@ export default class TaskModel extends AbstractModel { this.checked = checked; } - setDeleted() { - this.deleted = true; - } - start() { this.active = true; this.addDateWhenWasInProgress(new Date()); @@ -128,9 +135,10 @@ export default class TaskModel extends AbstractModel { } private addDateWhenWasInProgress(date: Date) { - const found = this.datesInProgress.find((d) => isSameDay(d, date)); + const normalDate = startOfDay(date); + const found = this.datesInProgress.find((d) => isSameDay(d, normalDate)); if (!found) { - this.datesInProgress.push(date); + this.datesInProgress.push(normalDate); } } } diff --git a/src/models/TaskWithDurationModel.ts b/src/models/TaskWithDurationModel.ts new file mode 100644 index 0000000..c09f12b --- /dev/null +++ b/src/models/TaskWithDurationModel.ts @@ -0,0 +1,8 @@ +import TaskModel from './TaskModel'; + +export default class TaskWithDurationModel { + constructor( + public task: TaskModel, + public duration: number // milliseconds + ) {} +} diff --git a/src/services/BadgeService.ts b/src/modules/BadgeService.ts similarity index 100% rename from src/services/BadgeService.ts rename to src/modules/BadgeService.ts diff --git a/src/services/RootStore.ts b/src/modules/RootStore.ts similarity index 100% rename from src/services/RootStore.ts rename to src/modules/RootStore.ts diff --git a/src/services/projects/ProjectFactory.ts b/src/modules/projects/ProjectFactory.ts similarity index 100% rename from src/services/projects/ProjectFactory.ts rename to src/modules/projects/ProjectFactory.ts diff --git a/src/services/projects/ProjectRepository.ts b/src/modules/projects/ProjectRepository.ts similarity index 100% rename from src/services/projects/ProjectRepository.ts rename to src/modules/projects/ProjectRepository.ts diff --git a/src/services/projects/ProjectService.ts b/src/modules/projects/ProjectService.ts similarity index 100% rename from src/services/projects/ProjectService.ts rename to src/modules/projects/ProjectService.ts diff --git a/src/services/projects/ProjectStore.ts b/src/modules/projects/ProjectStore.ts similarity index 90% rename from src/services/projects/ProjectStore.ts rename to src/modules/projects/ProjectStore.ts index 97f5dfc..f5a691d 100644 --- a/src/services/projects/ProjectStore.ts +++ b/src/modules/projects/ProjectStore.ts @@ -27,6 +27,7 @@ export default class ProjectStore { setActiveProject(projectId: string) { this.activeProject = projectId; + this.projects = this.projects.slice(); // trigger to update view } setProjectProps( @@ -36,6 +37,7 @@ export default class ProjectStore { ) { project.title = title; project.color = color || ''; + this.projects = this.projects.slice(); this.projectService.save(this.projects); } @@ -47,8 +49,9 @@ export default class ProjectStore { } add(project: ProjectModel) { - this.projects.push(project); - this.projects = this.projects.slice(); + const newProjects = this.projects.slice(); + newProjects.push(project); + this.projects = newProjects; this.projectService.save(this.projects); } diff --git a/src/services/tasks/TaskFactory.ts b/src/modules/tasks/TaskFactory.ts similarity index 100% rename from src/services/tasks/TaskFactory.ts rename to src/modules/tasks/TaskFactory.ts diff --git a/src/services/tasks/TaskRepository.ts b/src/modules/tasks/TaskRepository.ts similarity index 100% rename from src/services/tasks/TaskRepository.ts rename to src/modules/tasks/TaskRepository.ts diff --git a/src/services/tasks/TaskService.ts b/src/modules/tasks/TaskService.ts similarity index 100% rename from src/services/tasks/TaskService.ts rename to src/modules/tasks/TaskService.ts diff --git a/src/services/tasks/TaskStore.ts b/src/modules/tasks/TaskStore.ts similarity index 94% rename from src/services/tasks/TaskStore.ts rename to src/modules/tasks/TaskStore.ts index 109562f..5806dfb 100644 --- a/src/services/tasks/TaskStore.ts +++ b/src/modules/tasks/TaskStore.ts @@ -1,5 +1,4 @@ import { makeAutoObservable } from 'mobx'; -import { ipcRenderer } from 'electron'; import TaskService from './TaskService'; import TaskModel, { ITimeRangeModel } from '../../models/TaskModel'; @@ -61,7 +60,7 @@ export default class TaskStore { } for (const tasks of Object.values(this.tasks)) { - TreeModelStoreHelper.getFlatItemsRecursive(tasks, condition, result); + TreeModelStoreHelper.getFlatItemsRecursiveBase(tasks, condition, result); } return result; } @@ -130,13 +129,10 @@ export default class TaskStore { } if (Array.isArray(this.tasks[projectId])) { - const found: TaskModel[] = []; - TreeModelStoreHelper.getFlatItemsRecursive( + return TreeModelStoreHelper.getFlatItemsRecursive( this.tasks[projectId], - condition, - found - ); - return found.map((f) => f.key); + condition + ).map((task) => task.key); } return []; } diff --git a/src/package.json b/src/package.json index 15aa796..d25fdbb 100644 --- a/src/package.json +++ b/src/package.json @@ -1,7 +1,7 @@ { "name": "time-tracker", "productName": "TimeTracker", - "version": "1.0.0", + "version": "1.0.1", "description": "Start and stop time, jump between tasks, and add details on how time was spent.", "main": "./main.prod.js", "author": { diff --git a/src/screens/Main.tsx b/src/screens/Main.tsx index 5c26536..d88e139 100644 --- a/src/screens/Main.tsx +++ b/src/screens/Main.tsx @@ -3,10 +3,11 @@ import { Route, Switch, Link, Redirect } from 'react-router-dom'; import { Layout } from 'antd'; import { observer } from 'mobx-react'; -import Projects from './projects/Projects'; +import ProjectsScreen from './projects/ProjectsScreen'; import TaskControl from '../components/TaskControl/TaskControl'; import HeaderMenu from '../components/HeaderMenu/HeaderMenu'; -import HoursView from './hours/HoursView'; +import HoursScreen from './hours/HoursScreen'; +import Dashboard from './dashboard/Dashboard'; const { Header } = Layout; @@ -20,6 +21,9 @@ export default observer(function Main() { Projects + + Dashboard + @@ -28,10 +32,13 @@ export default observer(function Main() { - + - + + + + diff --git a/src/screens/dashboard/Dashboard.tsx b/src/screens/dashboard/Dashboard.tsx new file mode 100644 index 0000000..1789a39 --- /dev/null +++ b/src/screens/dashboard/Dashboard.tsx @@ -0,0 +1,54 @@ +import React, { useMemo, useState } from 'react'; +import { createUseStyles } from 'react-jss'; +import { Layout, Space } from 'antd'; +import { observer } from 'mobx-react'; + +import SelectDate from '../../components/SelectDate/SelectDate'; +import rootStore from '../../modules/RootStore'; +import { getTasksWithTotalTimeForDay } from '../../helpers/TaskHelper'; +import HoursWithDuration from './components/HoursWithDuration'; + +const { tasksStore } = rootStore; + +const Dashboard: React.FC = observer(() => { + const classes = useStyles(); + + const [date, setDate] = useState(new Date()); + const tasks = useMemo(() => tasksStore.getTasksByDate(date), [date]); + const tasksWithDuration = useMemo( + () => getTasksWithTotalTimeForDay(tasks, date), + [tasks, date] + ); + + return ( + + +
+ +
+ {tasksWithDuration.map((tasksWithDuration) => ( + + ))} +
+
+ ); +}); + +const useStyles = createUseStyles({ + root: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + overflowY: 'auto', + padding: 12, + }, + selectDate: { + display: 'flex', + justifyContent: 'center', + }, +}); + +export default Dashboard; diff --git a/src/screens/dashboard/components/HoursWithDuration.tsx b/src/screens/dashboard/components/HoursWithDuration.tsx new file mode 100644 index 0000000..1760d44 --- /dev/null +++ b/src/screens/dashboard/components/HoursWithDuration.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Card } from 'antd'; +import { createUseStyles } from 'react-jss'; + +import TaskWithDurationModel from '../../../models/TaskWithDurationModel'; +import { msToTime } from '../../../helpers/DateTime'; + +interface IHoursWithDurationProps { + taskWithDuration: TaskWithDurationModel; +} + +const HoursWithDuration: React.FC = ({ + taskWithDuration, +}: IHoursWithDurationProps) => { + const classes = useStyles(); + + return ( + +
{taskWithDuration.task.title}
+
{msToTime(taskWithDuration.duration)}
+
+ ); +}; + +const useStyles = createUseStyles({ + root: { + width: 300, + '& .ant-card-body': { + padding: 8, + }, + }, +}); + +export default HoursWithDuration; diff --git a/src/screens/hours/HoursView.tsx b/src/screens/hours/HoursScreen.tsx similarity index 51% rename from src/screens/hours/HoursView.tsx rename to src/screens/hours/HoursScreen.tsx index 6396ab4..070e08f 100644 --- a/src/screens/hours/HoursView.tsx +++ b/src/screens/hours/HoursScreen.tsx @@ -1,19 +1,34 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Layout, Space } from 'antd'; import { observer } from 'mobx-react'; +import * as Sentry from '@sentry/electron'; -import rootStore from '../../services/RootStore'; +import rootStore from '../../modules/RootStore'; import HoursCard from './components/HoursCard/HoursCard'; -import getTimeItems from '../../services/TaskTimeItem'; -import SelectDate from './components/SelectDate/SelectDate'; +import { getTimeItems } from '../../helpers/TaskHelper'; +import SelectDate from '../../components/SelectDate/SelectDate'; import TimeRangeModal from '../../components/TimeRangeModal/TimeRangeModal'; import TaskTimeItemModel from '../../models/TaskTimeItemModel'; import { Undefined } from '../../types/CommonTypes'; import TotalHours from './components/TotalHours/TotalHours'; import { createUseStyles } from 'react-jss'; +import { mapCurrentNext } from '../../helpers/ArrayHelper'; +import { ITimeRangeModel } from '../../models/TaskModel'; +import { msToTime } from '../../helpers/DateTime'; const { tasksStore } = rootStore; +function getDiff( + prev: ITimeRangeModel | undefined, + next: ITimeRangeModel | undefined +) { + if (prev?.end && next?.start) { + return msToTime(next.start.getTime() - prev.end.getTime()); + } + + return ''; +} + export default observer(function HoursView() { const classes = useStyles(); const [date, setDate] = useState(new Date()); @@ -24,18 +39,28 @@ export default observer(function HoursView() { const tasks = useMemo(() => tasksStore.getTasksByDate(date), [date]); const timeItems = getTimeItems(tasks, date); + useEffect(() => { + Sentry.captureException(new Error(`${process.env.NODE_ENV} exception`)); + }, []); + return ( - {timeItems.map((taskTime, index) => ( - setCurrentTaskTime(taskTime)} - /> - ))} +
+ {mapCurrentNext(timeItems, (item, next, index) => ( +
+ setCurrentTaskTime(taskTime)} + /> +
+ {getDiff(item.time, next?.time)} +
+
+ ))} +
= ( + props: ILabelWithTooltipProps +) => { + const { icon, label, tooltip } = props; + const classes = useStyles(); + + return ( + +
+ {icon && } + {label} +
+
+ ); +}; + +const useStyles = createUseStyles({ + iconAndLabel: { + display: 'flex', + alignItems: 'center', + }, + icon: { + fontSize: 18, + color: '#5f6368', + marginRight: 4, + }, +}); + +export default LabelWithTooltip; diff --git a/src/screens/hours/components/TotalHours/TotalHours.tsx b/src/screens/hours/components/TotalHours/TotalHours.tsx index 93ea3c1..45df78b 100644 --- a/src/screens/hours/components/TotalHours/TotalHours.tsx +++ b/src/screens/hours/components/TotalHours/TotalHours.tsx @@ -1,14 +1,69 @@ import React from 'react'; import { observer } from 'mobx-react'; +import { Space, Tooltip } from 'antd'; -import { useTimeItemsDuration } from '../../../../hooks/TaskHooks'; +import * as TaskHooks from '../../../../hooks/TaskHooks'; import TaskTimeItemModel from '../../../../models/TaskTimeItemModel'; +import { + EIGHT_HOURS, + estimateWorkingTimeEnd, + getTime, + msToTime, +} from '../../../../helpers/DateTime'; +import LabelWithTooltip, { ILabelWithTooltipProps } from './LabelWithTooltip'; interface TotalHoursProps { timeItems: TaskTimeItemModel[]; } -export default observer(function TotalHours({ timeItems }: TotalHoursProps) { - const duration = useTimeItemsDuration(timeItems); - return
{duration}
; +const TotalHours = observer((props: TotalHoursProps) => { + const { timeItems } = props; + + const { durationMs, restMs } = TaskHooks.useTimeItemsDuration(timeItems); + const startWorkingTime = TaskHooks.useStartWorkingTime(timeItems); + const estimatedWorkingTimeEnd = estimateWorkingTimeEnd( + startWorkingTime, + restMs + ); + const restHoursMs = EIGHT_HOURS - durationMs; + + if (!timeItems.length) { + return null; + } + + const items: ILabelWithTooltipProps[] = [ + { + label: getTime(startWorkingTime), + tooltip: 'Start time', + }, + { + icon: 'mi-work-outline', + label: msToTime(durationMs, false), + tooltip: 'Working hours', + }, + { + icon: 'mi-local-cafe', + label: msToTime(restMs, false), + tooltip: 'Rest hours', + }, + { + icon: 'mi-notifications', + label: getTime(estimatedWorkingTimeEnd), + tooltip: 'Estimated end of working hours', + }, + { + label: msToTime(restHoursMs, false), + tooltip: 'Left to work', + }, + ]; + + return ( + + {items.map((props, index) => ( + + ))} + + ); }); + +export default TotalHours; diff --git a/src/screens/projects/Projects.tsx b/src/screens/projects/ProjectsScreen.tsx similarity index 92% rename from src/screens/projects/Projects.tsx rename to src/screens/projects/ProjectsScreen.tsx index d349c09..0186e1f 100644 --- a/src/screens/projects/Projects.tsx +++ b/src/screens/projects/ProjectsScreen.tsx @@ -3,9 +3,10 @@ import { Button, Layout, Space } from 'antd'; import { observer } from 'mobx-react'; import { Key } from 'rc-tree/lib/interface'; import { createUseStyles } from 'react-jss'; +import { PlusOutlined } from '@ant-design/icons'; import TaskInput from './components/TaskInput'; -import rootStore from '../../services/RootStore'; +import rootStore from '../../modules/RootStore'; import TreeList from './components/TreeList'; import TaskModel from '../../models/TaskModel'; import ProjectModel from '../../models/ProjectModel'; @@ -14,7 +15,6 @@ import TaskNode from './components/TaskNode/TaskNode'; import DrawerTask from './components/DrawerTask/DrawerTask'; import ProjectNode from './components/ProjectNode/ProjectNode'; import EditProjectModal from './components/ProjectModals/EditProjectModal'; -import { PlusOutlined } from '@ant-design/icons'; const { Sider } = Layout; @@ -27,7 +27,7 @@ const TaskList = TreeList( }, { checkable: true, - onCheck(keys) { + onCheck(keys: any) { tasksStore.checkTasks(projectStore.activeProject, keys as string[]); }, getCheckedKeys() { @@ -45,8 +45,14 @@ const ProjectList = TreeList( projectStore.set(list); }, { + selectable: false, titleRender(project: ProjectModel) { - return ; + return ( + + ); }, } ); @@ -78,7 +84,7 @@ export default observer(function Projects() { return ( - +