diff --git a/.env.example b/.env.example index 5754edb..e3705b2 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,5 @@ GA_UACODE=UA-XXXXXXXXX-X # But dotenv-webpack plugin will replace it by string and parse by Terser will fail. # NODE_DEBUG=request # debug requests in analytics # DEBUG_PROD=true +# FORCE_ENABLE_ANALYTICS=true +# FORCE_ENABLE_SENTRY=true diff --git a/.erb/configs/webpack.config.main.prod.babel.js b/.erb/configs/webpack.config.main.prod.babel.js index 94598ba..ed7016b 100644 --- a/.erb/configs/webpack.config.main.prod.babel.js +++ b/.erb/configs/webpack.config.main.prod.babel.js @@ -6,11 +6,12 @@ import path from 'path'; import webpack from 'webpack'; import { merge } from 'webpack-merge'; import TerserPlugin from 'terser-webpack-plugin'; -import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; +import Dotenv from 'dotenv-webpack'; +// import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; + import baseConfig from './webpack.config.base'; import CheckNodeEnv from '../scripts/CheckNodeEnv'; import DeleteSourceMaps from '../scripts/DeleteSourceMaps'; -import Dotenv from 'dotenv-webpack'; CheckNodeEnv('production'); DeleteSourceMaps(); @@ -46,11 +47,11 @@ export default merge(baseConfig, { }, plugins: [ - new BundleAnalyzerPlugin({ - analyzerMode: - process.env.OPEN_ANALYZER === 'true' ? 'server' : 'disabled', - openAnalyzer: process.env.OPEN_ANALYZER === 'true', - }), + // new BundleAnalyzerPlugin({ + // analyzerMode: + // process.env.OPEN_ANALYZER === 'true' ? 'server' : 'disabled', + // openAnalyzer: process.env.OPEN_ANALYZER === 'true', + // }), /** * Create global constants which can be configured at compile time. diff --git a/.erb/configs/webpack.config.renderer.dev.babel.js b/.erb/configs/webpack.config.renderer.dev.babel.js index 26ffb9b..abc6168 100644 --- a/.erb/configs/webpack.config.renderer.dev.babel.js +++ b/.erb/configs/webpack.config.renderer.dev.babel.js @@ -103,48 +103,48 @@ export default merge(baseConfig, { ], }, // SASS support - compile all .global.scss files and pipe it to style.css - { - test: /\.global\.(scss|sass)$/, - use: [ - { - loader: 'style-loader', - }, - { - loader: 'css-loader', - options: { - sourceMap: true, - }, - }, - { - loader: 'sass-loader', - }, - ], - }, - // SASS support - compile all other .scss files and pipe it to style.css - { - test: /^((?!\.global).)*\.(scss|sass)$/, - use: [ - { - loader: 'style-loader', - }, - { - loader: '@teamsupercell/typings-for-css-modules-loader', - }, - { - loader: 'css-loader', - options: { - modules: { - localIdentName: '[name]__[local]__[hash:base64:5]', - }, - sourceMap: true, - importLoaders: 1, - }, - }, - { - loader: 'sass-loader', - }, - ], - }, + // { + // test: /\.global\.(scss|sass)$/, + // use: [ + // { + // loader: 'style-loader', + // }, + // { + // loader: 'css-loader', + // options: { + // sourceMap: true, + // }, + // }, + // { + // loader: 'sass-loader', + // }, + // ], + // }, + // // SASS support - compile all other .scss files and pipe it to style.css + // { + // test: /^((?!\.global).)*\.(scss|sass)$/, + // use: [ + // { + // loader: 'style-loader', + // }, + // { + // loader: '@teamsupercell/typings-for-css-modules-loader', + // }, + // { + // loader: 'css-loader', + // options: { + // modules: { + // localIdentName: '[name]__[local]__[hash:base64:5]', + // }, + // sourceMap: true, + // importLoaders: 1, + // }, + // }, + // { + // loader: 'sass-loader', + // }, + // ], + // }, { test: /\.less$/, use: [ diff --git a/.erb/configs/webpack.config.renderer.prod.babel.js b/.erb/configs/webpack.config.renderer.prod.babel.js index bd4d2d7..5d7df82 100644 --- a/.erb/configs/webpack.config.renderer.prod.babel.js +++ b/.erb/configs/webpack.config.renderer.prod.babel.js @@ -5,11 +5,12 @@ import path from 'path'; import webpack from 'webpack'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; -import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; -import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; +// import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; +// import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; import { merge } from 'webpack-merge'; import TerserPlugin from 'terser-webpack-plugin'; import Dotenv from 'dotenv-webpack'; + import baseConfig from './webpack.config.base'; import CheckNodeEnv from '../scripts/CheckNodeEnv'; import DeleteSourceMaps from '../scripts/DeleteSourceMaps'; @@ -152,7 +153,7 @@ export default merge(baseConfig, { new TerserPlugin({ parallel: true, }), - new CssMinimizerPlugin(), + // new CssMinimizerPlugin(), ], }, @@ -180,10 +181,10 @@ export default merge(baseConfig, { filename: 'style.css', }), - new BundleAnalyzerPlugin({ - analyzerMode: - process.env.OPEN_ANALYZER === 'true' ? 'server' : 'disabled', - openAnalyzer: process.env.OPEN_ANALYZER === 'true', - }), + // new BundleAnalyzerPlugin({ + // analyzerMode: + // process.env.OPEN_ANALYZER === 'true' ? 'server' : 'disabled', + // openAnalyzer: process.env.OPEN_ANALYZER === 'true', + // }), ], }); diff --git a/.erb/img/erb-banner.png b/.erb/img/erb-banner.png deleted file mode 100644 index 1d92246..0000000 Binary files a/.erb/img/erb-banner.png and /dev/null differ diff --git a/.erb/img/erb-logo.png b/.erb/img/erb-logo.png deleted file mode 100644 index 97a5661..0000000 Binary files a/.erb/img/erb-logo.png and /dev/null differ diff --git a/.erb/img/eslint-padded-90.png b/.erb/img/eslint-padded-90.png deleted file mode 100644 index d3c59cb..0000000 Binary files a/.erb/img/eslint-padded-90.png and /dev/null differ diff --git a/.erb/img/eslint-padded.png b/.erb/img/eslint-padded.png deleted file mode 100644 index d6f9a1a..0000000 Binary files a/.erb/img/eslint-padded.png and /dev/null differ diff --git a/.erb/img/eslint.png b/.erb/img/eslint.png deleted file mode 100755 index 7fc0e98..0000000 Binary files a/.erb/img/eslint.png and /dev/null differ diff --git a/.erb/img/jest-padded-90.png b/.erb/img/jest-padded-90.png deleted file mode 100644 index 1f942a8..0000000 Binary files a/.erb/img/jest-padded-90.png and /dev/null differ diff --git a/.erb/img/jest-padded.png b/.erb/img/jest-padded.png deleted file mode 100644 index ff62dd0..0000000 Binary files a/.erb/img/jest-padded.png and /dev/null differ diff --git a/.erb/img/jest.png b/.erb/img/jest.png deleted file mode 100644 index 3923b0b..0000000 Binary files a/.erb/img/jest.png and /dev/null differ diff --git a/.erb/img/js-padded.png b/.erb/img/js-padded.png deleted file mode 100644 index d1f0ba6..0000000 Binary files a/.erb/img/js-padded.png and /dev/null differ diff --git a/.erb/img/js.png b/.erb/img/js.png deleted file mode 100755 index 7f49239..0000000 Binary files a/.erb/img/js.png and /dev/null differ diff --git a/.erb/img/npm.png b/.erb/img/npm.png deleted file mode 100755 index 06c649a..0000000 Binary files a/.erb/img/npm.png and /dev/null differ diff --git a/.erb/img/react-padded-90.png b/.erb/img/react-padded-90.png deleted file mode 100644 index d10d9f2..0000000 Binary files a/.erb/img/react-padded-90.png and /dev/null differ diff --git a/.erb/img/react-padded.png b/.erb/img/react-padded.png deleted file mode 100644 index a5f2429..0000000 Binary files a/.erb/img/react-padded.png and /dev/null differ diff --git a/.erb/img/react-router-padded-90.png b/.erb/img/react-router-padded-90.png deleted file mode 100644 index 711d741..0000000 Binary files a/.erb/img/react-router-padded-90.png and /dev/null differ diff --git a/.erb/img/react-router-padded.png b/.erb/img/react-router-padded.png deleted file mode 100644 index a0c4ac9..0000000 Binary files a/.erb/img/react-router-padded.png and /dev/null differ diff --git a/.erb/img/react-router.png b/.erb/img/react-router.png deleted file mode 100644 index a926678..0000000 Binary files a/.erb/img/react-router.png and /dev/null differ diff --git a/.erb/img/react.png b/.erb/img/react.png deleted file mode 100755 index b82579d..0000000 Binary files a/.erb/img/react.png and /dev/null differ diff --git a/.erb/img/webpack-padded-90.png b/.erb/img/webpack-padded-90.png deleted file mode 100644 index 4076d5b..0000000 Binary files a/.erb/img/webpack-padded-90.png and /dev/null differ diff --git a/.erb/img/webpack-padded.png b/.erb/img/webpack-padded.png deleted file mode 100644 index cc1d1a2..0000000 Binary files a/.erb/img/webpack-padded.png and /dev/null differ diff --git a/.erb/img/webpack.png b/.erb/img/webpack.png deleted file mode 100755 index bc437f0..0000000 Binary files a/.erb/img/webpack.png and /dev/null differ diff --git a/.erb/img/yarn-padded-90.png b/.erb/img/yarn-padded-90.png deleted file mode 100644 index a08b53b..0000000 Binary files a/.erb/img/yarn-padded-90.png and /dev/null differ diff --git a/.erb/img/yarn-padded.png b/.erb/img/yarn-padded.png deleted file mode 100644 index ca85ee2..0000000 Binary files a/.erb/img/yarn-padded.png and /dev/null differ diff --git a/.erb/img/yarn.png b/.erb/img/yarn.png deleted file mode 100644 index f406a47..0000000 Binary files a/.erb/img/yarn.png and /dev/null differ diff --git a/.github/tt-edit-project.png b/.github/tt-edit-project.png new file mode 100644 index 0000000..9a6a884 Binary files /dev/null and b/.github/tt-edit-project.png differ diff --git a/.github/tt-hours.png b/.github/tt-hours.png new file mode 100644 index 0000000..3d749ac Binary files /dev/null and b/.github/tt-hours.png differ diff --git a/.github/tt-projects.png b/.github/tt-projects.png new file mode 100644 index 0000000..5f7f59f Binary files /dev/null and b/.github/tt-projects.png differ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ddb02b1..7625ce8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,9 @@ on: push: branches: - master + pull_request: + branches: + - master jobs: publish: @@ -11,7 +14,7 @@ jobs: strategy: matrix: - os: [ macos-latest ] + os: [macos-latest] steps: - name: Checkout git repo @@ -20,7 +23,7 @@ jobs: - name: Install Node, NPM and Yarn uses: actions/setup-node@v1 with: - node-version: 15 + node-version: 16 - name: Get yarn cache directory path id: yarn-cache-dir-path @@ -33,12 +36,11 @@ jobs: key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-yarn- - - name: Install dependencies - run: | - yarn install --prefer-offline + - name: Install dependencies run: | yarn install + - name: Publish releases env: # These values are used for auto updates signing @@ -48,6 +50,10 @@ jobs: CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} # This is used for uploading release assets to github GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # App env + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + GA_UACODE: ${{ secrets.GA_UACODE }} run: | - yarn postinstall && yarn build && yarn electron-builder --publish always --win - # yarn postinstall && yarn build && yarn electron-builder --publish always --win --mac --linux + yarn postinstall + yarn build + yarn electron-builder --publish always --win --linux diff --git a/.gitignore b/.gitignore index dcebd27..80b6aec 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ dll main.js main.js.map +.run .idea npm-debug.log.* *.css.d.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 9993f5e..2bfea9d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,14 @@ "javascript.format.enable": false, "typescript.format.enable": false, + "editor.formatOnSave": true, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "search.exclude": { ".git": true, ".eslintcache": true, @@ -24,5 +32,7 @@ "test/**/__snapshots__": true, "yarn.lock": true, "*.{css,sass,scss}.d.ts": true - } + }, + + "cSpell.words": ["Popconfirm", "Sider"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c1e626e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-present Yadro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b5332cf..3c7fe6b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,10 @@ +

+ +

+

+ download +

+ # 🕘 TimeTracker Track time, add notes, view reports of your time, and more. @@ -13,11 +20,32 @@ Start and stop time, jump between tasks, and add details on how time was spent. - receive notifications when a task is in progress or when you are idle ## Screenshots - - - + + + + +## Download +**Windows** / **MacOS** / **Linux** *(not tested)* + +download +## Where to find app data files? +**Windows**: +``` +cd C:\Users\%username%\AppData\Roaming\YadroTimeTracker +``` +**MacOS**: +``` +cd ~/Library/Application\ Support/YadroTimeTracker +``` +You need to backup both `settings.json` and profile folder `profile1`. And also rest folders with profiles if you have. + +## Support +Please leave the feedback on [AlternativeTo](https://alternativeto.net/software/timetracker-by-yadro/about/) or [ProductHunt](https://www.producthunt.com/posts/timetracker) to support this app. + +TimeTracker - time tracker, todo list | Product Hunt + ## Starting Development Start the app in the `dev` environment: @@ -33,3 +61,5 @@ To package apps for the local platform: ```bash yarn package ``` + + diff --git a/assets/icon.icns b/assets/icon.icns index c2213ce..0ab9eff 100644 Binary files a/assets/icon.icns and b/assets/icon.icns differ diff --git a/assets/icon.ico b/assets/icon.ico index 66503e1..25c5c68 100644 Binary files a/assets/icon.ico and b/assets/icon.ico differ diff --git a/assets/icons/1024x1024.png b/assets/icons/1024x1024.png old mode 100755 new mode 100644 index 5940b65..44029e9 Binary files a/assets/icons/1024x1024.png and b/assets/icons/1024x1024.png differ diff --git a/assets/icons/128x128.png b/assets/icons/128x128.png old mode 100755 new mode 100644 index 14e578d..03dc8bf Binary files a/assets/icons/128x128.png and b/assets/icons/128x128.png differ diff --git a/assets/icons/16x16.png b/assets/icons/16x16.png old mode 100755 new mode 100644 index 260a46c..d7147cd Binary files a/assets/icons/16x16.png and b/assets/icons/16x16.png differ diff --git a/assets/icons/24x24.png b/assets/icons/24x24.png old mode 100755 new mode 100644 index 5617241..1aa4402 Binary files a/assets/icons/24x24.png and b/assets/icons/24x24.png differ diff --git a/assets/icons/256x256.png b/assets/icons/256x256.png old mode 100755 new mode 100644 index 755a6e5..c6bf700 Binary files a/assets/icons/256x256.png and b/assets/icons/256x256.png differ diff --git a/assets/icons/32x32.png b/assets/icons/32x32.png old mode 100755 new mode 100644 index 63423df..834b5a8 Binary files a/assets/icons/32x32.png and b/assets/icons/32x32.png differ diff --git a/assets/icons/48x48.png b/assets/icons/48x48.png old mode 100755 new mode 100644 index 74d87a0..a8c736a Binary files a/assets/icons/48x48.png and b/assets/icons/48x48.png differ diff --git a/assets/icons/512x512.png b/assets/icons/512x512.png old mode 100755 new mode 100644 index 313cd49..a52347e Binary files a/assets/icons/512x512.png and b/assets/icons/512x512.png differ diff --git a/assets/icons/64x64.png b/assets/icons/64x64.png old mode 100755 new mode 100644 index 6de0ec0..1511213 Binary files a/assets/icons/64x64.png and b/assets/icons/64x64.png differ diff --git a/assets/icons/96x96.png b/assets/icons/96x96.png old mode 100755 new mode 100644 index 8255ab5..418360a Binary files a/assets/icons/96x96.png and b/assets/icons/96x96.png differ diff --git a/package.json b/package.json index 5e8e118..f56f84e 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,12 @@ "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": "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", + "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 && 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" + "start:renderer": "cross-env NODE_ENV=development webpack serve --config ./.erb/configs/webpack.config.renderer.dev.babel.js", + "test": "jest", + "tsc": "tsc" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ @@ -112,6 +114,27 @@ "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|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", @@ -139,31 +162,33 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", "@teamsupercell/typings-for-css-modules-loader": "^2.4.0", "@types/history": "4.7.6", + "@types/jest": "^26.0.24", "@types/node": "14.14.10", - "@types/react": "^16.9.44", - "@types/react-dom": "^16.9.9", + "@types/react": "18.0.14", + "@types/react-dom": "17.0.11", "@types/react-router-dom": "^5.1.6", + "@types/universal-analytics": "0.4.5", + "@types/uuid": "^8.3.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", - "browserslist-config-erb": "^0.0.1", "chalk": "^4.1.0", "concurrently": "^5.3.0", "core-js": "^3.6.5", "cross-env": "^7.0.2", "css-loader": "^5.0.1", - "css-minimizer-webpack-plugin": "^2.0.0", "detect-port": "^1.3.0", "dotenv-webpack": "^7.0.3", - "electron": "^12.0.2", - "electron-builder": "^22.11.7", - "electron-devtools-installer": "git+https://github.com/MarshallOfSound/electron-devtools-installer.git", - "electron-notarize": "^1.0.0", - "electron-rebuild": "^2.3.2", + "electron": "19.0.0", + "electron-builder": "23.1.0", + "electron-devtools-installer": "3.2.0", + "electron-notarize": "1.2.1", + "electron-rebuild": "3.2.7", "eslint": "^7.5.0", "eslint-config-airbnb": "^18.2.0", "eslint-config-airbnb-typescript": "^12.0.0", @@ -172,6 +197,7 @@ "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", @@ -179,36 +205,33 @@ "eslint-plugin-react-hooks": "^4.0.8", "file-loader": "^6.0.0", "husky": "^4.2.5", + "identity-obj-proxy": "^3.0.0", + "jest": "^27.0.6", "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", "rimraf": "^3.0.0", - "sass-loader": "^10.1.0", "style-loader": "^2.0.0", "terser-webpack-plugin": "^5.0.3", - "typescript": "^4.0.5", + "typescript": "^4.3.5", "url-loader": "^4.1.0", "webpack": "^5.5.1", - "webpack-bundle-analyzer": "^4.1.0", "webpack-cli": "^4.2.0", - "webpack-dev-server": "^3.11.2", + "webpack-dev-server": "3.11.3", "webpack-merge": "^5.4.0", "yarn-deduplicate": "^3.1.0" }, "dependencies": { "@ant-design/colors": "6.0.0", - "@ant-design/icons": "4.6.2", + "@ant-design/icons": "4.7.0", "@sentry/electron": "2.5.0", - "antd": "4.16.7", - "caniuse-lite": "1.0.30001214", + "antd": "4.21.3", "clsx": "^1.1.1", "date-fns": "2.20.1", - "electron-debug": "^3.1.0", - "electron-log": "^4.2.4", - "electron-updater": "^4.3.4", + "electron-debug": "3.2.0", + "electron-log": "4.4.8", + "electron-updater": "5.0.5", "electron-windows-badge": "1.1.0", "history": "^5.0.0", "less": "4.1.1", @@ -216,24 +239,22 @@ "material-icons": "0.7.4", "mobx": "6.1.8", "mobx-react": "7.1.0", - "moment": "2.29.1", - "react": "^17.0.1", - "react-dom": "^17.0.1", + "moment": "2.29.4", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-hook-media-query": "^1.0.5", "react-jss": "^10.6.0", "react-router-dom": "^5.2.0", "regenerator-runtime": "^0.13.5", "source-map-support": "^0.5.19", - "universal-analytics": "^0.4.23" + "universal-analytics": "0.5.3", + "uuid": "^8.3.2" }, "devEngines": { - "node": ">=14.x", + "node": ">=16.x", "npm": ">=6.x", "yarn": ">=1.21.3" }, - "collective": { - "url": "https://opencollective.com/electron-react-boilerplate-594" - }, - "browserslist": [], "prettier": { "overrides": [ { diff --git a/src/App.global.less b/src/App.global.less index 63c517a..1ec37b4 100644 --- a/src/App.global.less +++ b/src/App.global.less @@ -1,7 +1,7 @@ @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"); + 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'; @@ -16,9 +16,3 @@ display: flex; align-items: center; } - -.flex-1 { - flex: 1 -} - -@purple: #713A91; diff --git a/src/base/AbstractModel.ts b/src/base/AbstractModel.ts index 21883a6..3612ef1 100644 --- a/src/base/AbstractModel.ts +++ b/src/base/AbstractModel.ts @@ -3,7 +3,7 @@ export default abstract class AbstractModel { return Object.keys(this); } - protected load(data: T) { + protected load(data: T) { if (data) { this.getAttributes().forEach((attribute) => { if (data.hasOwnProperty(attribute)) { diff --git a/src/base/TreeModelHelper.ts b/src/base/TreeModelHelper.ts deleted file mode 100644 index 46994e7..0000000 --- a/src/base/TreeModelHelper.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { ITreeItem } from '../types/ITreeItem'; - -const TreeModelHelper = { - modifyItemsWithIdsRecursive>( - treeItems: T[], - ids: string[], - fn: (treeItem: T, ids: string[]) => void - ) { - treeItems.forEach((item) => { - fn(item, ids); - if (Array.isArray(item.children) && item.children.length) { - TreeModelHelper.modifyItemsWithIdsRecursive(item.children, ids, fn); - } - }); - }, - - getItemRecursive>( - tasks: T[], - condition: (task: T) => boolean - ): T | undefined { - for (const task of tasks) { - if (condition(task)) { - return task; - } - if (Array.isArray(task.children)) { - const found = this.getItemRecursive(task.children, condition); - if (found) { - return found; - } - } - } - return undefined; - }, - - getFlatItemsRecursive>( - tree: T[], - condition: (task: T) => boolean - ): T[] { - const result: T[] = []; - - this.getFlatItemsRecursiveBase(tree, condition, result); - - return result; - }, - - getFlatItemsRecursiveBase>( - treeItems: T[], - condition: (item: T) => boolean, - result: T[] - ): T[] { - for (const item of treeItems) { - if (condition(item)) { - result.push(item); - } - if (Array.isArray(item.children)) { - this.getFlatItemsRecursiveBase(item.children, condition, result); - } - } - return result; - }, - - deleteItems>( - treeItems: T[], - condition: (task: T) => boolean - ): T[] { - const result = treeItems.filter((t) => !condition(t)); - for (let i = 0; i < result.length; i++) { - const task = treeItems[i]; - if (Array.isArray(task.children)) { - treeItems[i].children = this.deleteItems(task.children, condition); - } - } - return result; - }, -}; - -export default TreeModelHelper; diff --git a/src/base/repositories/AbstractFileRepository.ts b/src/base/repositories/AbstractFileRepository.ts index c170b3f..b2eefc1 100644 --- a/src/base/repositories/AbstractFileRepository.ts +++ b/src/base/repositories/AbstractFileRepository.ts @@ -1,5 +1,8 @@ +import { EChannels } from '../../main/EChannels'; + const fs = require('fs'); const path = require('path'); +import { ipcRenderer } from 'electron'; import FsHelper from '../../helpers/FsHelper'; import PromiseQueue from '../../helpers/PromiseQueueHellper'; @@ -9,6 +12,8 @@ const APP_DIR = ? 'YadroTimeTracker_test' : 'YadroTimeTracker'; +let _appDataPath: string = ''; + export default abstract class AbstractFileRepository { dirWithProfileData: string = 'profile1'; fileName: string = 'defaultFileName.json'; @@ -21,8 +26,12 @@ export default abstract class AbstractFileRepository { return `FileRepository [${filePath}/${this.fileName}]:`; } - private static get appDataFolder() { - return process.env.APPDATA || ''; + static get appDataFolder() { + if (_appDataPath) { + return _appDataPath; + } + _appDataPath = ipcRenderer.sendSync(EChannels.GetPathUserData); + return _appDataPath; } private get destFolder() { diff --git a/src/components/CircleButton/CircleButton.tsx b/src/components/CircleButton.tsx similarity index 100% rename from src/components/CircleButton/CircleButton.tsx rename to src/components/CircleButton.tsx diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..0922a97 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Layout } from 'antd'; +import { observer } from 'mobx-react'; +import useMediaQuery from 'react-hook-media-query'; +import { createUseStyles } from 'react-jss'; + +import HeaderLink from './HeaderLink'; +import Profile from './Profile'; +import TaskControl from './TaskControl'; +import ProgressBar from './ProgressBar'; + +const { Header: HeaderBase } = Layout; + +const query = '(min-width: 950px)'; +function Header() { + const style = useStyle(); + const isBigScreen = useMediaQuery(query); + + return ( + + + Hours + + + Projects + + + Dashboard + + {isBigScreen && } + + + + ); +} + +export default observer(Header); + +const useStyle = createUseStyles({ + flex1: { + flex: 1, + }, +}); diff --git a/src/components/HeaderMenu/HeaderMenu.tsx b/src/components/HeaderLink.tsx similarity index 86% rename from src/components/HeaderMenu/HeaderMenu.tsx rename to src/components/HeaderLink.tsx index a56c4aa..6bb3036 100644 --- a/src/components/HeaderMenu/HeaderMenu.tsx +++ b/src/components/HeaderLink.tsx @@ -6,7 +6,7 @@ interface HeaderMenuProps { children: React.ReactNode; } -export default observer(function HeaderMenu({ children }: HeaderMenuProps) { +export default observer(function HeaderLink({ children }: HeaderMenuProps) { const classes = useStyles(); return {children}; diff --git a/src/components/IconTile/IconTile.tsx b/src/components/IconTile.tsx similarity index 100% rename from src/components/IconTile/IconTile.tsx rename to src/components/IconTile.tsx diff --git a/src/components/PlayStopButton.tsx b/src/components/PlayStopButton.tsx new file mode 100644 index 0000000..f6babe1 --- /dev/null +++ b/src/components/PlayStopButton.tsx @@ -0,0 +1,51 @@ +import React, { SyntheticEvent, useCallback } from 'react'; +import { CaretRightFilled, PauseOutlined } from '@ant-design/icons'; +import { observer } from 'mobx-react'; +import { createUseStyles } from 'react-jss'; + +import CircleButton from './CircleButton'; +import rootStore from '../modules/RootStore'; +import TaskModel from '../modules/tasks/models/TaskModel'; + +const { tasksStore } = rootStore; + +interface PlayStopButtonProps { + task: TaskModel | undefined; + className?: string; +} + +function PlayStopButton({ task, className }: PlayStopButtonProps) { + const classes = useStyles(); + + const toggleTask = useCallback( + (e: SyntheticEvent) => { + e.stopPropagation(); + if (task) { + if (!task?.active) { + tasksStore.startTimer(task); + } else { + tasksStore.stopTimer(); + } + } + }, + [task] + ); + + return ( + + {!task?.active ? ( + + ) : ( + + )} + + ); +} + +export default observer(PlayStopButton); + +const useStyles = createUseStyles({ + icon: { + color: 'white', + }, +}); diff --git a/src/components/PlayStopButton/PlayStopButton.tsx b/src/components/PlayStopButton/PlayStopButton.tsx deleted file mode 100644 index 3f7089d..0000000 --- a/src/components/PlayStopButton/PlayStopButton.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, { SyntheticEvent } from 'react'; -import { CaretRightFilled, PauseOutlined } from '@ant-design/icons'; -import { observer } from 'mobx-react'; -import clsx from 'clsx'; -import { createUseStyles } from 'react-jss'; - -import CircleButton from '../CircleButton/CircleButton'; -import rootStore from '../../modules/RootStore'; -import TaskModel from '../../modules/tasks/models/TaskModel'; - -const { tasksStore } = rootStore; - -interface PlayStopButtonProps { - task: TaskModel | undefined; - className?: string; -} - -export default observer(function PlayStopButton({ - task, - className, -}: PlayStopButtonProps) { - const classes = useStyles(); - - function handleClick(e: SyntheticEvent) { - e.stopPropagation(); - if (task) { - if (!task?.active) { - tasksStore.startTimer(task); - } else { - tasksStore.stopTimer(); - } - } - } - - return ( - - {!task?.active ? ( - - ) : ( - - )} - - ); -}); - -const useStyles = createUseStyles({ - icon: { - color: 'white', - }, -}); diff --git a/src/components/Profile/Profile.tsx b/src/components/Profile.tsx similarity index 83% rename from src/components/Profile/Profile.tsx rename to src/components/Profile.tsx index ba31731..7725bcb 100644 --- a/src/components/Profile/Profile.tsx +++ b/src/components/Profile.tsx @@ -3,9 +3,9 @@ import { Space } from 'antd'; import { SettingOutlined } from '@ant-design/icons'; import { createUseStyles } from 'react-jss'; -import rootStore from '../../modules/RootStore'; -import useModal from '../../hooks/ModalHook'; -import SettingsModal from '../SettingsModal/SettingsModal'; +import rootStore from '../modules/RootStore'; +import useModal from '../hooks/ModalHook'; +import SettingsModal from './SettingsModal/SettingsModal'; const { settingsStore } = rootStore; diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx new file mode 100644 index 0000000..41804c1 --- /dev/null +++ b/src/components/ProgressBar.tsx @@ -0,0 +1,102 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { observer } from 'mobx-react'; +import { Slider } from 'antd'; +import { isSameDay } from 'date-fns'; +import { createUseStyles } from 'react-jss'; + +import rootStore from '../modules/RootStore'; +import { useInterval } from '../hooks/UseInterval'; +import { getStartWorkingTime, getTimeItems } from '../helpers/TaskHelper'; +import TaskTimeService from '../services/TaskTimeService'; +import { toTimeFormat } from '../helpers/DateTime'; + +const { tasksStore, settingsStore } = rootStore; + +function ProgressBar() { + const { settings } = settingsStore; + const workingHoursMs = settings.numberOfWorkingHours; + + const style = useStyle(); + + const [dayUpdateEveryDay, setDayUpdateEveryDay] = useState(new Date()); + const [_tick, setTick] = useState(new Date()); + + const shouldDayUpdate = useCallback(() => { + const now = new Date(); + setTick(now); + if (!isSameDay(dayUpdateEveryDay, now)) { + setDayUpdateEveryDay(now); + } + }, [dayUpdateEveryDay]); + + useInterval(shouldDayUpdate, 1000); + + const tasksByProject = useMemo(() => Object.values(tasksStore.tasks), [ + tasksStore.tasks, + tasksStore.versionHash, + ]); + + const tasks = useMemo(() => tasksStore.getTasksByDate(dayUpdateEveryDay), [ + tasksByProject, + dayUpdateEveryDay, + ]); + + const timeItems = useMemo(() => getTimeItems(tasks, dayUpdateEveryDay), [ + tasks, + dayUpdateEveryDay, + ]); + + const workingTimeStart = useMemo(() => getStartWorkingTime(timeItems), [ + timeItems, + ]); + + const timeRangeItems = useMemo(() => timeItems.map((t) => t.time), [ + timeItems, + ]); + + const { + estimatedWorkingTimeEnd, + progress, + realProgress, + } = TaskTimeService.getDayProgress( + timeRangeItems, + workingTimeStart, + workingHoursMs + ); + + const marks: Record = { + 0: toTimeFormat(workingTimeStart), + 100: toTimeFormat(estimatedWorkingTimeEnd), + }; + if (progress > 10 && progress < 90) { + marks[progress] = `${realProgress}%`; + } + + const tipFormatter = useMemo(() => { + if (progress <= 10 || progress >= 90) { + return () => `${realProgress}%`; + } + return null; + }, [progress, realProgress]); + + return ( + + ); +} + +const useStyle = createUseStyles({ + slider: { + '& .ant-slider-mark': { + '& .ant-slider-mark-text': { + color: 'white', + }, + }, + }, +}); + +export default observer(ProgressBar); diff --git a/src/components/SelectDate/SelectDate.tsx b/src/components/SelectDate.tsx similarity index 100% rename from src/components/SelectDate/SelectDate.tsx rename to src/components/SelectDate.tsx diff --git a/src/components/SettingsModal/SettingsModal.tsx b/src/components/SettingsModal/SettingsModal.tsx index 108abdd..5426db2 100644 --- a/src/components/SettingsModal/SettingsModal.tsx +++ b/src/components/SettingsModal/SettingsModal.tsx @@ -21,6 +21,7 @@ import IModalProps from '../../types/IModalProps'; import NewProfilePopover from './NewProfilePopover'; import { timeToMs } from '../../helpers/DateTime'; import { DEFAULT_SETTINGS } from '../../modules/settings/models/SettingsModel'; +import AbstractFileRepository from '../../base/repositories/AbstractFileRepository'; const { settingsStore } = rootStore; @@ -130,6 +131,14 @@ const SettingsModal: React.VFC = observer( Test Sentry )} + {(process.env.NODE_ENV === 'development' || + process.env.DEBUG_PROD === 'true') && ( + <> +

{`APPDATA: ${AbstractFileRepository.appDataFolder}`}

+

{`SENTRY_DSN: ${process.env.SENTRY_DSN}`}

+

{`GA_UACODE: ${process.env.GA_UACODE}`}

+ + )} ); } diff --git a/src/components/TaskControl.tsx b/src/components/TaskControl.tsx new file mode 100644 index 0000000..6f4fe0a --- /dev/null +++ b/src/components/TaskControl.tsx @@ -0,0 +1,68 @@ +import React, { useMemo } from 'react'; +import { observer } from 'mobx-react'; +import { createUseStyles } from 'react-jss'; + +import rootStore from '../modules/RootStore'; +import * as TaskHooks from '../hooks/TaskHooks'; +import PlayStopButton from './PlayStopButton'; +import { PURPLE_COLOR } from '../consts'; + +const { tasksStore, projectStore } = rootStore; + +function TaskControl() { + const styles = useStyles(); + const task = tasksStore.activeTask; + const duration = TaskHooks.useTaskDuration(task); + + const project = useMemo(() => { + return projectStore.get(task?.projectId || ''); + }, [task]); + + if (task) { + return ( + +
+ {project?.title} + {task.title} +
+ {duration} + +
+ ); + } + return No active tasks; +} + +export default observer(TaskControl); + +const useStyles = createUseStyles({ + root: { + display: 'inline-flex', + alignItems: 'center', + height: '44px', + marginLeft: '20px', + padding: '8px', + borderRadius: '5px', + lineHeight: '16px', + color: 'white', + backgroundColor: PURPLE_COLOR, + }, + project: { + fontSize: '12px', + }, + info: { + display: 'inline-flex', + flex: 1, + flexDirection: 'column', + marginRight: '8px', + width: '140px', + '& span': { + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + overflow: 'hidden', + }, + }, + duration: { + marginRight: '8px', + }, +}); diff --git a/src/components/TaskControl/TaskControl.less b/src/components/TaskControl/TaskControl.less deleted file mode 100644 index 6ea85ae..0000000 --- a/src/components/TaskControl/TaskControl.less +++ /dev/null @@ -1,35 +0,0 @@ -@import '../../App.global'; - -.task-control { - display: inline-flex; - align-items: center; - height: 44px; - margin-left: 20px; - padding: 8px; - border-radius: 5px; - line-height: 16px; - color: white; - background-color: @purple; /* TODO refactor and use Theme */ -} - -.task-control__info { - display: inline-flex; - flex: 1; - flex-direction: column; - margin-right: 8px; - width: 140px; -} - -.task-control__project { - font-size: 12px; -} - -.task-control__info span { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; -} - -.task-control__duration { - margin-right: 8px; -} diff --git a/src/components/TaskControl/TaskControl.tsx b/src/components/TaskControl/TaskControl.tsx deleted file mode 100644 index 70cf67e..0000000 --- a/src/components/TaskControl/TaskControl.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { useMemo } from 'react'; -import { observer } from 'mobx-react'; -import { PauseOutlined } from '@ant-design/icons'; - -import './TaskControl.less'; - -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 = TaskHooks.useTaskDuration(task); - - const project = useMemo(() => { - return projectStore.get(task?.projectId || ''); - }, [task]); - - if (task) { - return ( - -
- {project?.title} - {task.title} -
- {duration} - tasksStore.stopTimer()}> - - -
- ); - } - return No active tasks; -}); diff --git a/src/components/TimeRangeModal/components/TimeRangeDuration.tsx b/src/components/TimeRangeModal/TimeRangeDuration.tsx similarity index 68% rename from src/components/TimeRangeModal/components/TimeRangeDuration.tsx rename to src/components/TimeRangeModal/TimeRangeDuration.tsx index 50736a8..27790c0 100644 --- a/src/components/TimeRangeModal/components/TimeRangeDuration.tsx +++ b/src/components/TimeRangeModal/TimeRangeDuration.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import * as TaskHooks from '../../../hooks/TaskHooks'; -import { ITimeRangeModel } from '../../../modules/tasks/models/TaskModel'; +import * as TaskHooks from '../../hooks/TaskHooks'; +import { ITimeRangeModel } from '../../modules/tasks/models/TaskModel'; interface TimeRangeDurationProps { timeRange?: ITimeRangeModel; diff --git a/src/components/TimeRangeModal/TimeRangeModal.tsx b/src/components/TimeRangeModal/TimeRangeModal.tsx index 9018763..383834e 100644 --- a/src/components/TimeRangeModal/TimeRangeModal.tsx +++ b/src/components/TimeRangeModal/TimeRangeModal.tsx @@ -10,7 +10,7 @@ import rootStore from '../../modules/RootStore'; import TaskTimeItemModel from '../../modules/tasks/models/TaskTimeItemModel'; import { ITimeRangeModel } from '../../modules/tasks/models/TaskModel'; import { Undefined } from '../../types/CommonTypes'; -import TimeRangeDuration from './components/TimeRangeDuration'; +import TimeRangeDuration from './TimeRangeDuration'; import IModalProps from '../../types/IModalProps'; const { tasksStore } = rootStore; @@ -74,7 +74,7 @@ const TimeRangeModal = observer( function handleDelete() { if (taskTime) { - tasksStore.deleteTime(taskTime.task, taskTime.index); + tasksStore.removeTime(taskTime.task, taskTime.index); } onClose(); } diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..452891d --- /dev/null +++ b/src/config.ts @@ -0,0 +1,5 @@ +export const Features = { + myDay: false, +}; + +export const THROTTLE_SAVE_JSON_MS = 500; diff --git a/src/consts/index.ts b/src/consts/index.ts new file mode 100644 index 0000000..012e828 --- /dev/null +++ b/src/consts/index.ts @@ -0,0 +1 @@ +export const PURPLE_COLOR = '#713A91'; diff --git a/src/helpers/ArrayHelper.ts b/src/helpers/ArrayHelper.ts index 6b660fc..48f70fe 100644 --- a/src/helpers/ArrayHelper.ts +++ b/src/helpers/ArrayHelper.ts @@ -1,8 +1,8 @@ type CallbackPrev = (prev: T | undefined, cur: T, index: number) => R; -export function iterPrevCurrent( +export function iterPrevCurrent( items: T[], - callback: CallbackPrev + callback: CallbackPrev ) { for (let i = 0; i < items.length; i++) { if (i === 0) { @@ -13,11 +13,11 @@ export function iterPrevCurrent( } } -export function mapPrevCurrent( +export function mapPrevCurrent( items: T[], - callback: CallbackPrev -): R[] { - const result: R[] = []; + callback: CallbackPrev +): Result[] { + const result: Result[] = []; for (let i = 0; i < items.length; i++) { if (i === 0) { result.push(callback(undefined, items[i], i)); @@ -30,11 +30,11 @@ export function mapPrevCurrent( type CallbackNext = (cur: T, next: T | undefined, index: number) => R; -export function mapCurrentNext( +export function mapCurrentNext( items: T[], - callback: CallbackNext -): R[] { - const result: R[] = []; + callback: CallbackNext +): Result[] { + const result: Result[] = []; for (let i = 0; i < items.length; i++) { if (i === items.length - 1) { result.push(callback(items[i], undefined, i)); @@ -44,3 +44,11 @@ export function mapCurrentNext( } return result; } + +export function last(arr: T[]): T | undefined { + return arr[arr.length - 1]; +} + +export function first(arr: T[]): T | undefined { + return arr[0]; +} diff --git a/src/helpers/DateTime.ts b/src/helpers/DateTime.ts index cda855d..8125e9d 100644 --- a/src/helpers/DateTime.ts +++ b/src/helpers/DateTime.ts @@ -1,5 +1,6 @@ import { ITimeRangeModel } from '../modules/tasks/models/TaskModel'; -import { format } from 'date-fns'; +import { format, isSameMonth, isSameYear } from 'date-fns'; + import { iterPrevCurrent } from './ArrayHelper'; function timePad(time: number): string { @@ -45,6 +46,17 @@ export function msToTime(s: number, showSeconds: boolean = true) { return timeItemsToString(sign, hrs, mins, secs, showSeconds); } +export function taskLastActiveDateFormat(date: Date) { + const now = new Date(); + if (isSameMonth(date, now)) { + return format(date, 'E dd'); + } + if (isSameYear(date, now)) { + return format(date, 'dd/MM'); + } + return format(date, 'dd/MM/yy'); +} + export function timeToMs(date: Date) { const hours = date.getHours(); const minutes = date.getMinutes(); @@ -72,18 +84,13 @@ export function calcDurationGaps(taskTime: ITimeRangeModel[]): number { } }); - 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) { +export function toTimeFormat(date: Date | undefined) { if (!date) { return NO_TIME; } @@ -99,3 +106,7 @@ export function estimateWorkingTimeEnd( ? new Date(startDate.getTime() + restTimeMs + workingHoursMs) : undefined; } + +export function getRestHoursMs(workingHoursMs: number, durationMs: number) { + return workingHoursMs - durationMs; +} diff --git a/src/helpers/StopPropagation.ts b/src/helpers/StopPropagation.ts new file mode 100644 index 0000000..7d09bef --- /dev/null +++ b/src/helpers/StopPropagation.ts @@ -0,0 +1,12 @@ +import React from 'react'; + +export const stopPropagation = (e: React.SyntheticEvent | undefined) => { + e?.stopPropagation(); +}; + +export const stopPropagationAndRun = (fn: () => void) => { + return (e: React.SyntheticEvent | undefined) => { + e?.stopPropagation(); + fn(); + }; +}; diff --git a/src/helpers/TaskHelper.ts b/src/helpers/TaskHelper.ts index bcaf5a6..611044e 100644 --- a/src/helpers/TaskHelper.ts +++ b/src/helpers/TaskHelper.ts @@ -1,8 +1,9 @@ -import { isSameDay, compareAsc } from 'date-fns'; +import { isSameDay, compareAsc, isBefore } from 'date-fns'; import TaskModel from '../modules/tasks/models/TaskModel'; import TaskTimeItemModel from '../modules/tasks/models/TaskTimeItemModel'; import TaskWithDurationModel from '../modules/tasks/models/TaskWithDurationModel'; +import TreeModelHelper from './TreeModelHelper'; /** * Returns TaskTimeItemModel contains time range @@ -18,23 +19,22 @@ import TaskWithDurationModel from '../modules/tasks/models/TaskWithDurationModel */ export function getTimeItems( tasks: TaskModel[], - date: Date, + date: Date ): TaskTimeItemModel[] { - let taskTime: TaskTimeItemModel[] = []; + const taskTime: TaskTimeItemModel[] = []; tasks.forEach((task) => { - const taskTimeItems: TaskTimeItemModel[] = []; for (let i = 0; i < task.time.length; i++) { const range = task.time[i]; if ( isSameDay(range.start, date) || (range.end && isSameDay(range.end, date)) ) { - taskTimeItems.push(new TaskTimeItemModel(task, range, i)); + taskTime.push(new TaskTimeItemModel(task, range, i)); } } - taskTime = taskTime.concat(taskTimeItems); }); - taskTime = taskTime.sort((a, b) => compareAsc(a.time.start, b.time.start)); + taskTime.sort((a, b) => compareAsc(a.time.start, b.time.start)); + return taskTime; } @@ -45,9 +45,42 @@ export function getTimeItems( */ export function getTasksWithTotalTimeForDay( tasks: TaskModel[], - date: Date, + date: Date ): TaskWithDurationModel[] { return tasks.map( - (task) => new TaskWithDurationModel(task, task.getDurationByDate(date)), + (task) => new TaskWithDurationModel(task, task.getDurationByDate(date)) ); } + +/** + * @example task1 → task2 → task + * @param task + */ +export function getTaskTitlesPath(task: TaskModel) { + const titlePath: string[] = []; + + function processParent(nParent: TaskModel) { + titlePath.push(nParent.title); + } + + TreeModelHelper.walkToParent(processParent, task); + let titlePathStr = titlePath.reverse().join(' → '); + if (titlePath.length > 0) { + titlePathStr += ' →'; + } + return titlePathStr; +} + +export function getStartWorkingTime( + timeItems: TaskTimeItemModel[] +): Date | undefined { + let minTime: Date | undefined; + let tmpStartTime: Date | undefined; + timeItems.forEach((item) => { + tmpStartTime = item.time.start; + if (!minTime || isBefore(tmpStartTime, minTime)) { + minTime = tmpStartTime; + } + }); + return minTime; +} diff --git a/src/helpers/Throttle.ts b/src/helpers/Throttle.ts new file mode 100644 index 0000000..3cddc32 --- /dev/null +++ b/src/helpers/Throttle.ts @@ -0,0 +1,14 @@ +export default function throttle(func: (...args: any[]) => void, ms: number) { + let timer: number | undefined; + + function wrapper(this: any, ...args: any[]) { + if (timer !== undefined) { + window.clearTimeout(timer); + } + timer = window.setTimeout(() => { + func.apply(this, args); + }, ms); + } + + return wrapper; +} diff --git a/src/helpers/TreeModelHelper.test.ts b/src/helpers/TreeModelHelper.test.ts new file mode 100644 index 0000000..f668b87 --- /dev/null +++ b/src/helpers/TreeModelHelper.test.ts @@ -0,0 +1,83 @@ +import TreeModelHelper from './TreeModelHelper'; +import TaskFactory from '../modules/tasks/TaskFactory'; +import { IJsonTaskModel } from '../modules/tasks/models/TaskModel'; +import { TasksByProject } from '../modules/tasks/models/TasksByProject'; + +describe('TreeModelHelper', () => { + let testTasks: TasksByProject | undefined; + beforeEach(() => { + const factory = new TaskFactory(); + /* Structure: + -task1 + --task1-1 + ---task-1-1-1 + -task2 + --task21 + --task22 + */ + const task111: Partial = { + key: '111', + title: 'task1-1-1', + parent: undefined, + children: [], + }; + const task11: Partial = { + key: '11', + title: 'task1-1', + parent: undefined, + children: [task111 as IJsonTaskModel], + }; + const task1: Partial = { + key: '1', + title: 'task1', + parent: undefined, + children: [task11 as IJsonTaskModel], + }; + + task111.parent = task11 as IJsonTaskModel; + task11.parent = task1 as IJsonTaskModel; + + const task21: Partial = { + key: '21', + title: 'task21', + parent: undefined, + children: [], + }; + const task22: Partial = { + key: '22', + title: 'task22', + parent: undefined, + children: [], + }; + const task2: Partial = { + key: '2', + title: 'task2', + parent: undefined, + children: [task21, task22] as IJsonTaskModel[], + }; + task21.parent = task2 as IJsonTaskModel; + task22.parent = task2 as IJsonTaskModel; + + testTasks = factory.createTasks(({ + proj: [task1, task2], + } as unknown) as TasksByProject); + }); + + test('getPathToNode #1', () => { + if (!testTasks) { + throw new Error(); + } + const task111 = testTasks?.proj?.[0]?.children?.[0]?.children?.[0]; + const result = task111 ? TreeModelHelper.getPathToNode(task111) : undefined; + expect(result).toStrictEqual(['1', '11', '111']); + }); + + test('getPathToNode #2', () => { + if (!testTasks) { + throw new Error(); + } + const task22 = testTasks?.proj?.[1]?.children?.[0]; + const result = task22 ? TreeModelHelper.getPathToNode(task22) : undefined; + expect(result).toStrictEqual(['2', '21']); + }); +}); diff --git a/src/helpers/TreeModelHelper.ts b/src/helpers/TreeModelHelper.ts new file mode 100644 index 0000000..09fe94f --- /dev/null +++ b/src/helpers/TreeModelHelper.ts @@ -0,0 +1,258 @@ +import { ITreeItem, ITreeItemWithParent } from '../types/ITreeItem'; +import { TaskInMyDay } from '../modules/tasks/models/TaskInMyDay'; +import TaskModel from '../modules/tasks/models/TaskModel'; +import TaskFactory from '../modules/tasks/TaskFactory'; +import ProjectModel from '../modules/projects/models/ProjectModel'; + +const TreeModelHelper = { + getPathToNode(node: T) { + const result: string[] = []; + + let ptrNode: T | undefined = node; + while (ptrNode) { + result.unshift(ptrNode.key); + // @ts-ignore + ptrNode = ptrNode.parent; + } + + return result; + }, + + copyItemsToTreeUnderProject( + project: ProjectModel | undefined, + sourceTree: TaskModel[], + destTree: TaskInMyDay[], + keysToNode: string[] + ): boolean { + if (!project) { + return false; + } + + let destProject = destTree.find((node) => node.key === project.key); + if (!destProject) { + destProject = TaskFactory.createTaskModelProxy( + new TaskModel({ + key: project.key, + projectId: project.key, + title: project.title, + active: false, + checked: false, + children: [], + datesInProgress: [], + details: [], + expanded: true, + inMyDay: new Date().toString(), + parent: undefined, + time: [], + withoutActions: true, + }) + ); + destTree.push(destProject); + } + + return TreeModelHelper.copyItemsToTree( + sourceTree, + destProject.children || [], + keysToNode + ); + }, + + /** + * Make a copy of tasks to 'My Day' + */ + copyItemsToTree( + sourceTree: TaskModel[], + destTree: TaskInMyDay[], + keysToNode: string[] + ) { + let keyIdx = 0; + let sourceChildren = sourceTree; + let destChildren = destTree; + + if (keysToNode.length === 1) { + const source = sourceChildren.find((node) => node.key === keysToNode[0]); + if (source) { + destChildren.push(TaskFactory.createTaskModelProxy(source)); + } + return !!source; + } + + do { + const nextSourceNode = sourceChildren.find( + (task) => task.key === keysToNode[keyIdx] + ); + if (!nextSourceNode) { + return false; + } + + const nextDestNode = destChildren.find( + (task) => task.key === keysToNode[keyIdx] + ); + + if (nextDestNode) { + // We already have a copy of node, go on + keyIdx++; + sourceChildren = nextSourceNode.children || []; + destChildren = nextDestNode.children || []; + } else { + // Make a copy from this node + const restKeysToNode = keysToNode.slice(keyIdx); + return TreeModelHelper.copySubItemsToTree( + sourceChildren, + destChildren, + restKeysToNode + ); + } + } while (keyIdx < keysToNode.length); + + return true; + }, + + copySubItemsToTree( + sourceTree: TaskModel[], + destTree: TaskInMyDay[], + keysToNode: string[] + ) { + if (!sourceTree) { + return false; + } + + let keyIdx = 0; + let destChildren = destTree; + let sourceNode = sourceTree.find((node) => node.key === keysToNode[keyIdx]); + + if (!sourceNode) { + return false; + } + + while (true) { + const copyNode = TaskFactory.createTaskModelProxy(sourceNode); + destChildren.push(copyNode); + + keyIdx++; + if (keyIdx === keysToNode.length) { + return true; + } + + destChildren = copyNode.children || []; + sourceNode = sourceNode.children?.find( + (node) => node.key === keysToNode[keyIdx] + ); + if (!sourceNode) { + return false; + } + } + }, + + walkRecursive( + fn: (item: T, parent?: T) => void, + treeItems: T[], + parent?: T + ) { + treeItems.forEach((item) => { + fn(item, parent); + if (item.children?.length) { + this.walkRecursive(fn, item.children as T[], item); + } + }); + }, + + walkToParent( + fn: (nParent: T) => void, + treeItem: T + ) { + if (treeItem.parent) { + fn(treeItem.parent as T); + this.walkToParent(fn, treeItem.parent as T); + } + }, + + modifyItemsWithIdsRecursive( + treeItems: T[], + ids: string[], + fn: (treeItem: T, ids: string[]) => void + ) { + treeItems.forEach((item) => { + fn(item, ids); + if (Array.isArray(item.children) && item.children.length) { + this.modifyItemsWithIdsRecursive(item.children as T[], ids, fn); + } + }); + }, + + getItemRecursive( + items: T[], + condition: (task: T) => boolean + ): T | undefined { + for (const item of items) { + if (condition(item)) { + return item; + } + if (item.children?.length) { + const found = this.getItemRecursive(item.children as T[], condition); + if (found) { + return found; + } + } + } + return undefined; + }, + + getFlatItemsRecursive( + tree: T[], + condition: (task: T) => boolean + ): T[] { + const result: T[] = []; + + this.getFlatItemsRecursiveBase(tree, condition, result); + + return result; + }, + + getFlatItemsRecursiveBase( + treeItems: T[], + condition: (item: T) => boolean, + result: T[] + ): T[] { + treeItems.forEach((item) => { + if (condition(item)) { + result.push(item); + } + if (item.children?.length) { + this.getFlatItemsRecursiveBase(item.children as T[], condition, result); + } + }); + return result; + }, + + deleteItems( + treeItems: T[], + condition: (item: T) => boolean + ): T[] { + const result = treeItems.filter((t) => !condition(t)); + result.forEach((item) => { + if (item.children?.length) { + item.children = this.deleteItems(item.children as T[], condition); + } + }); + return result; + }, + + fillParent(items: T[]) { + TreeModelHelper.walkRecursive(setParent, items); + }, + + clearParent(items: T[]) { + TreeModelHelper.walkRecursive(clearParent, items); + }, +}; + +const setParent = (item: T, parent?: T) => { + item.parent = parent; +}; + +const clearParent = (item: T) => { + item.parent = undefined; +}; + +export default TreeModelHelper; diff --git a/src/hooks/TaskHooks.ts b/src/hooks/TaskHooks.ts index f527bc6..4e78287 100644 --- a/src/hooks/TaskHooks.ts +++ b/src/hooks/TaskHooks.ts @@ -1,5 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { isBefore } from 'date-fns'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { calcDuration, calcDurationGaps, msToTime } from '../helpers/DateTime'; import TaskModel, { ITimeRangeModel } from '../modules/tasks/models/TaskModel'; @@ -105,17 +104,3 @@ export function useTimeRangeDuration(timeRange: ITimeRangeModel | undefined) { 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/hooks/UseInterval.ts b/src/hooks/UseInterval.ts new file mode 100644 index 0000000..41d8c20 --- /dev/null +++ b/src/hooks/UseInterval.ts @@ -0,0 +1,8 @@ +import { useEffect } from 'react'; + +export function useInterval(fn: () => void, intervalMs: number = 1000) { + useEffect(() => { + const intervalId = setInterval(fn, intervalMs); + return () => clearInterval(intervalId); + }, [fn, intervalMs]); +} diff --git a/src/index.html b/src/index.html index 66f5a2a..661d7eb 100644 --- a/src/index.html +++ b/src/index.html @@ -1,5 +1,5 @@ - + Time Tracker diff --git a/src/index.tsx b/src/index.tsx index 6a6df9e..4546051 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,9 @@ import React from 'react'; + +// Due to issue with antd, we need to use old react api import { render } from 'react-dom'; +// FIXME: import { createRoot } from 'react-dom/client'; + import dotenv from 'dotenv'; import App from './App'; @@ -11,3 +15,4 @@ dotenv.config(); initSentry(); render(, document.getElementById('root')); +// createRoot(document.getElementById('root')).render(); diff --git a/src/main.dev.ts b/src/main.dev.ts index 2bf7043..f252fff 100644 --- a/src/main.dev.ts +++ b/src/main.dev.ts @@ -12,15 +12,17 @@ import log from 'electron-log'; Object.assign(console, log.functions); import 'core-js/stable'; import 'regenerator-runtime/runtime'; +import { initSentry } from './shared/initSentry'; +initSentry(); + import path from 'path'; import { app, BrowserWindow, shell } from 'electron'; import { autoUpdater } from 'electron-updater'; +// @ts-ignore import Badge from 'electron-windows-badge'; +import './main/IpcMain'; import MenuBuilder from './menu'; -import { initSentry } from './shared/initSentry'; - -initSentry(); console.log('Working path:', app.getAppPath()); @@ -87,7 +89,6 @@ const createWindow = async () => { process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true' ) { - // Doesnt work await installExtensions(); } diff --git a/src/main/EChannels.ts b/src/main/EChannels.ts new file mode 100644 index 0000000..4009b4f --- /dev/null +++ b/src/main/EChannels.ts @@ -0,0 +1,3 @@ +export enum EChannels { + GetPathUserData = 'get-path-userdata', +} diff --git a/src/main/IpcMain.ts b/src/main/IpcMain.ts new file mode 100644 index 0000000..fbf64df --- /dev/null +++ b/src/main/IpcMain.ts @@ -0,0 +1,8 @@ +// main process +import { ipcMain, app } from 'electron'; + +import { EChannels } from './EChannels'; + +ipcMain.on(EChannels.GetPathUserData, (event) => { + event.returnValue = app.getPath('appData'); +}); diff --git a/src/menu.ts b/src/menu.ts index 8dd0c97..b0b949a 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -1,5 +1,4 @@ import { - app, Menu, shell, BrowserWindow, @@ -11,6 +10,9 @@ interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions { submenu?: DarwinMenuItemConstructorOptions[] | Menu; } +const isDev = + process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'; + export default class MenuBuilder { mainWindow: BrowserWindow; @@ -26,13 +28,11 @@ export default class MenuBuilder { this.setupDevelopmentEnvironment(); } - // const template = - // process.platform === 'darwin' - // ? this.buildDarwinTemplate() - // : this.buildDefaultTemplate(); - // - // const menu = Menu.buildFromTemplate(template); - const menu = Menu.buildFromTemplate([]); + const template = + process.platform === 'darwin' ? this.buildDarwinTemplate() : []; + // : this.buildDefaultTemplate(); // Disable menu for Win and Linux + + const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); return menu; @@ -54,143 +54,45 @@ export default class MenuBuilder { } buildDarwinTemplate(): MenuItemConstructorOptions[] { - const subMenuAbout: DarwinMenuItemConstructorOptions = { - label: 'Electron', + const none = [] as any; + const subMenuApp: DarwinMenuItemConstructorOptions = { + label: 'TimeTracker', submenu: [ - { - label: 'About ElectronReact', - selector: 'orderFrontStandardAboutPanel:', - }, - { type: 'separator' }, - { label: 'Services', submenu: [] }, - { type: 'separator' }, - { - label: 'Hide ElectronReact', - accelerator: 'Command+H', - selector: 'hide:', - }, - { - label: 'Hide Others', - accelerator: 'Command+Shift+H', - selector: 'hideOtherApplications:', - }, - { label: 'Show All', selector: 'unhideAllApplications:' }, + { role: 'about' }, + ...(isDev ? [{ type: 'separator' }, { role: 'services' }] : none), { type: 'separator' }, - { - label: 'Quit', - accelerator: 'Command+Q', - click: () => { - app.quit(); - }, - }, - ], - }; - const subMenuEdit: DarwinMenuItemConstructorOptions = { - label: 'Edit', - submenu: [ - { label: 'Undo', accelerator: 'Command+Z', selector: 'undo:' }, - { label: 'Redo', accelerator: 'Shift+Command+Z', selector: 'redo:' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, { type: 'separator' }, - { label: 'Cut', accelerator: 'Command+X', selector: 'cut:' }, - { label: 'Copy', accelerator: 'Command+C', selector: 'copy:' }, - { label: 'Paste', accelerator: 'Command+V', selector: 'paste:' }, - { - label: 'Select All', - accelerator: 'Command+A', - selector: 'selectAll:', - }, + { role: 'quit' }, ], }; - const subMenuViewDev: MenuItemConstructorOptions = { + const subMenuView: DarwinMenuItemConstructorOptions = { label: 'View', submenu: [ - { - label: 'Reload', - accelerator: 'Command+R', - click: () => { - this.mainWindow.webContents.reload(); - }, - }, - { - label: 'Toggle Full Screen', - accelerator: 'Ctrl+Command+F', - click: () => { - this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); - }, - }, - { - label: 'Toggle Developer Tools', - accelerator: 'Alt+Command+I', - click: () => { - this.mainWindow.webContents.toggleDevTools(); - }, - }, - ], - }; - const subMenuViewProd: MenuItemConstructorOptions = { - label: 'View', - submenu: [ - { - label: 'Toggle Full Screen', - accelerator: 'Ctrl+Command+F', - click: () => { - this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); - }, - }, - ], - }; - const subMenuWindow: DarwinMenuItemConstructorOptions = { - label: 'Window', - submenu: [ - { - label: 'Minimize', - accelerator: 'Command+M', - selector: 'performMiniaturize:', - }, - { label: 'Close', accelerator: 'Command+W', selector: 'performClose:' }, + ...(isDev + ? [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + ] + : none), + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, { type: 'separator' }, - { label: 'Bring All to Front', selector: 'arrangeInFront:' }, - ], - }; - const subMenuHelp: MenuItemConstructorOptions = { - label: 'Help', - submenu: [ - { - label: 'Learn More', - click() { - shell.openExternal('https://electronjs.org'); - }, - }, - { - label: 'Documentation', - click() { - shell.openExternal( - 'https://github.com/electron/electron/tree/master/docs#readme' - ); - }, - }, - { - label: 'Community Discussions', - click() { - shell.openExternal('https://www.electronjs.org/community'); - }, - }, - { - label: 'Search Issues', - click() { - shell.openExternal('https://github.com/electron/electron/issues'); - }, - }, + { role: 'togglefullscreen' }, ], }; - const subMenuView = - process.env.NODE_ENV === 'development' || - process.env.DEBUG_PROD === 'true' - ? subMenuViewDev - : subMenuViewProd; - - return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp]; + return [ + subMenuApp, + { role: 'editMenu' }, + subMenuView, + { role: 'windowMenu' }, + ]; } buildDefaultTemplate() { diff --git a/src/modules/RootStore.ts b/src/modules/RootStore.ts index d4f8b1b..7fd240d 100644 --- a/src/modules/RootStore.ts +++ b/src/modules/RootStore.ts @@ -20,7 +20,7 @@ export class RootStore { } deleteProject(project: ProjectModel) { - this.tasksStore.deleteProjectTasks(project.key); + this.tasksStore.removeProjectTasks(project.key); this.projectStore.delete(project); } } diff --git a/src/modules/projects/ProjectFactory.ts b/src/modules/projects/ProjectFactory.ts index 8322b75..672c40e 100644 --- a/src/modules/projects/ProjectFactory.ts +++ b/src/modules/projects/ProjectFactory.ts @@ -1,3 +1,27 @@ import AbstractFactory from '../../base/AbstractFactory'; +import ProjectModel, { + DEFAULT_PROJECT_ID, + DEFAULT_PROJECTS, + IJsonProjectItem, +} from './models/ProjectModel'; +import { Features } from '../../config'; -export default class ProjectFactory extends AbstractFactory {} +export default class ProjectFactory extends AbstractFactory { + createProjects(projectItems: IJsonProjectItem[]): ProjectModel[] { + if (Features.myDay) { + const hasMyDay = projectItems.find( + (p) => p.key === DEFAULT_PROJECT_ID.MyDay + ); + if (!hasMyDay) { + const myDayProj = DEFAULT_PROJECTS.find( + (p) => p.key === DEFAULT_PROJECT_ID.MyDay + ); + if (myDayProj) { + projectItems.unshift(myDayProj); + } + } + } + + return this.createList(ProjectModel, projectItems); + } +} diff --git a/src/modules/projects/ProjectRepository.ts b/src/modules/projects/ProjectRepository.ts index 9ced8ec..9642637 100644 --- a/src/modules/projects/ProjectRepository.ts +++ b/src/modules/projects/ProjectRepository.ts @@ -1,5 +1,8 @@ import AbstractFileRepository from '../../base/repositories/AbstractFileRepository'; +import { IJsonProjectItem } from './models/ProjectModel'; -export default class ProjectRepository extends AbstractFileRepository { +export default class ProjectRepository extends AbstractFileRepository< + IJsonProjectItem[] +> { fileName = 'projects.json'; } diff --git a/src/modules/projects/ProjectService.ts b/src/modules/projects/ProjectService.ts index 0a980c3..650916d 100644 --- a/src/modules/projects/ProjectService.ts +++ b/src/modules/projects/ProjectService.ts @@ -1,7 +1,12 @@ -import ProjectModel, { DEFAULT_PROJECTS } from './models/ProjectModel'; +import ProjectModel, { + DEFAULT_PROJECTS, + IJsonProjectItem, +} from './models/ProjectModel'; import ProjectFactory from './ProjectFactory'; import ProjectRepository from './ProjectRepository'; import AbstractServiceWithProfile from '../../base/AbstractServiceWithProfile'; +import TreeModelHelper from '../../helpers/TreeModelHelper'; +import { toJS } from 'mobx'; export default class ProjectService extends AbstractServiceWithProfile< ProjectModel[] @@ -11,10 +16,21 @@ export default class ProjectService extends AbstractServiceWithProfile< getAll(): ProjectModel[] { const data = this.repository.restore(DEFAULT_PROJECTS); - return this.factory.createList(ProjectModel, data); + ProjectService.fillParent(data); + return this.factory.createProjects(data); } save(data: ProjectModel[]): void { - this.repository.save(data); + const copyData = toJS(data); + ProjectService.clearParent(copyData); + this.repository.save(copyData); + } + + private static fillParent(data: IJsonProjectItem[]) { + TreeModelHelper.fillParent(data); + } + + private static clearParent(data: ProjectModel[]) { + TreeModelHelper.clearParent(data); } } diff --git a/src/modules/projects/ProjectStore.ts b/src/modules/projects/ProjectStore.ts index 0042a06..97d47ee 100644 --- a/src/modules/projects/ProjectStore.ts +++ b/src/modules/projects/ProjectStore.ts @@ -2,7 +2,7 @@ import { autorun, makeAutoObservable } from 'mobx'; import ProjectModel from './models/ProjectModel'; import ProjectService from './ProjectService'; -import TreeModelHelper from '../../base/TreeModelHelper'; +import TreeModelHelper from '../../helpers/TreeModelHelper'; import { Undefined } from '../../types/CommonTypes'; import { RootStore } from '../RootStore'; import GaService from '../../services/gaService/GaService'; diff --git a/src/modules/projects/models/ProjectModel.ts b/src/modules/projects/models/ProjectModel.ts index fd47c7d..f120e84 100644 --- a/src/modules/projects/models/ProjectModel.ts +++ b/src/modules/projects/models/ProjectModel.ts @@ -1,22 +1,44 @@ import * as colors from '@ant-design/colors'; +import { makeObservable, observable } from 'mobx'; import AbstractModel from '../../../base/AbstractModel'; -import { ITreeItem } from '../../../types/ITreeItem'; +import { ITreeItemWithParent } from '../../../types/ITreeItem'; -export const DEFAULT_PROJECTS: any[] = [ +export enum DEFAULT_PROJECT_ID { + MyDay = '0', + Inbox = '1', +} + +export const DEFAULT_PROJECTS: IJsonProjectItem[] = [ + // { + // key: DEFAULT_PROJECT_ID.MyDay, + // title: 'My Day', + // color: colors.yellow.primary || '', + // deletable: false, + // expanded: false, + // }, { - key: '1', + key: DEFAULT_PROJECT_ID.Inbox, title: 'Inbox', - color: colors.blue, + color: colors.blue.primary || '', + deletable: false, + expanded: false, + parent: undefined, }, ]; -interface IJsonProjectItem extends ITreeItem { +export interface IJsonProjectItem extends ITreeItemWithParent { color: string; + expanded: boolean; + deletable: boolean; + children?: IJsonProjectItem[]; } -interface IProjectModel extends ITreeItem { +interface IProjectModel extends ITreeItemWithParent { color: string; + expanded: boolean; + deletable: boolean; + children?: IProjectModel[]; } export default class ProjectModel extends AbstractModel @@ -25,11 +47,28 @@ export default class ProjectModel extends AbstractModel title: string = ''; color: string = ''; expanded: boolean = false; + deletable: boolean = true; children?: ProjectModel[] = []; + parent: ProjectModel | undefined; constructor(props: IJsonProjectItem) { super(); - this.load(props); // TODO вынести итератор - this.children = props.children?.map((json) => new ProjectModel(json)) || []; + + const newProps = { + ...props, + children: props.children?.map((json) => new ProjectModel(json)), + }; + + this.load(newProps); + + makeObservable(this, { + key: observable, + title: observable, + color: observable, + expanded: observable, + deletable: observable, + children: observable, + // parent: none, + }); } } diff --git a/src/modules/tasks/TaskFactory.ts b/src/modules/tasks/TaskFactory.ts index 7c1402c..4b62249 100644 --- a/src/modules/tasks/TaskFactory.ts +++ b/src/modules/tasks/TaskFactory.ts @@ -1,6 +1,8 @@ import AbstractFactory from '../../base/AbstractFactory'; -import TasksByProject from './models/TasksByProject'; +import { TasksByProject } from './models/TasksByProject'; import TaskModel from './models/TaskModel'; +import { TaskInMyDay, taskModelProxyHandler } from './models/TaskInMyDay'; +import { DEFAULT_PROJECT_ID } from '../projects/models/ProjectModel'; export default class TaskFactory extends AbstractFactory { createTasks(data: TasksByProject): TasksByProject { @@ -8,6 +10,15 @@ export default class TaskFactory extends AbstractFactory { Object.keys(data).forEach((projectId) => { newData[projectId] = this.createList(TaskModel, data[projectId]); }); + + newData[DEFAULT_PROJECT_ID.MyDay] = []; + return newData; } + + static createTaskModelProxy(taskModel: TaskModel): TaskInMyDay { + const target = new TaskInMyDay(taskModel, []); + + return new Proxy(target, taskModelProxyHandler); + } } diff --git a/src/modules/tasks/TaskRepository.ts b/src/modules/tasks/TaskRepository.ts index acecda8..85653c6 100644 --- a/src/modules/tasks/TaskRepository.ts +++ b/src/modules/tasks/TaskRepository.ts @@ -1,5 +1,5 @@ import AbstractFileRepository from '../../base/repositories/AbstractFileRepository'; -import TasksByProject from './models/TasksByProject'; +import { TasksByProject } from './models/TasksByProject'; export default class TaskRepository extends AbstractFileRepository< TasksByProject diff --git a/src/modules/tasks/TaskService.ts b/src/modules/tasks/TaskService.ts index 70dd95c..c08e31c 100644 --- a/src/modules/tasks/TaskService.ts +++ b/src/modules/tasks/TaskService.ts @@ -1,7 +1,10 @@ +import { toJS } from 'mobx'; + import TaskRepository from './TaskRepository'; import TaskFactory from './TaskFactory'; -import TasksByProject from './models/TasksByProject'; +import { TasksByProject } from './models/TasksByProject'; import AbstractServiceWithProfile from '../../base/AbstractServiceWithProfile'; +import TreeModelHelper from '../../helpers/TreeModelHelper'; export default class TaskService extends AbstractServiceWithProfile< TasksByProject @@ -11,10 +14,25 @@ export default class TaskService extends AbstractServiceWithProfile< getAll(): TasksByProject { const data: TasksByProject = this.repository.restore({}); + TaskService.fillParent(data); return this.factory.createTasks(data); } save(data: TasksByProject) { - this.repository.save(data); + const copyData = toJS(data); + TaskService.clearParent(copyData); + this.repository.save(copyData); + } + + private static fillParent(data: TasksByProject) { + Object.values(data).forEach((projectTasks) => { + TreeModelHelper.fillParent(projectTasks); + }); + } + + private static clearParent(data: TasksByProject) { + Object.values(data).forEach((projectTasks) => { + TreeModelHelper.clearParent(projectTasks); + }); } } diff --git a/src/modules/tasks/TaskStore.ts b/src/modules/tasks/TaskStore.ts index 73ce01e..ceb38c3 100644 --- a/src/modules/tasks/TaskStore.ts +++ b/src/modules/tasks/TaskStore.ts @@ -1,23 +1,34 @@ import { autorun, makeAutoObservable } from 'mobx'; - +import { v4 as uuid } from 'uuid'; import TaskService from './TaskService'; import TaskModel, { ITimeRangeModel } from './models/TaskModel'; -import TasksByProject from '../../modules/tasks/models/TasksByProject'; -import TreeModelHelper from '../../base/TreeModelHelper'; +import { + Task, + TasksByProject, +} from '../../modules/tasks/models/TasksByProject'; +import TreeModelHelper from '../../helpers/TreeModelHelper'; import BadgeService from '../BadgeService'; -import { RootStore } from '../RootStore'; +import rootStore, { RootStore } from '../RootStore'; import GaService from '../../services/gaService/GaService'; import { EEventCategory, ETasksEvents, ETimeRangeEvents, } from '../../services/gaService/EEvents'; +import { DEFAULT_PROJECT_ID } from '../projects/models/ProjectModel'; +import throttle from '../../helpers/Throttle'; +import { THROTTLE_SAVE_JSON_MS } from '../../config'; export default class TaskStore { tasks: TasksByProject = {}; activeTask: TaskModel | undefined; + versionHash = uuid(); private tasksService = new TaskService(); private interval: NodeJS.Timeout | undefined; + private saveInStorage = throttle(() => { + this.tasksService.save(this.tasks); + this.updateVersion(); + }, THROTTLE_SAVE_JSON_MS); constructor(private rootStore: RootStore) { makeAutoObservable(this); @@ -31,26 +42,26 @@ export default class TaskStore { set(projectId: string, tasksInProject: TaskModel[]) { this.tasks[projectId] = tasksInProject; - this.tasksService.save(this.tasks); + this.saveInStorage(); } setTime(task: TaskModel, timeIndex: number, timeRange: ITimeRangeModel) { task.time[timeIndex] = timeRange; - this.tasksService.save(this.tasks); + this.saveInStorage(); GaService.event(EEventCategory.TimeRange, ETimeRangeEvents.Update); } - deleteTime(task: TaskModel, timeIndex: number) { + removeTime(task: TaskModel, timeIndex: number) { if (task.active) { this.stopTimer(); } - task.time.splice(timeIndex, 1); - this.tasksService.save(this.tasks); + task.time.splice(timeIndex, 1); // TODO move to task + this.saveInStorage(); GaService.event(EEventCategory.TimeRange, ETimeRangeEvents.Delete); } - getTasks(projectId: string): TaskModel[] { + getTasks(projectId: string): Task[] { return this.tasks[projectId] || []; } @@ -86,18 +97,33 @@ export default class TaskStore { if (!Array.isArray(this.tasks[projectId])) { this.tasks[projectId] = []; } - this.tasks[projectId].push(task); - this.tasks[projectId] = this.tasks[projectId].slice(); - this.tasksService.save(this.tasks); + this.tasks[projectId] = [...this.tasks[projectId], task]; + this.saveInStorage(); GaService.event(EEventCategory.Tasks, ETasksEvents.Create); } - delete(task: TaskModel) { + addToMyDay(task: TaskModel) { + task.inMyDay = new Date(); + + const pathToNode = TreeModelHelper.getPathToNode(task); + + TreeModelHelper.copyItemsToTreeUnderProject( + rootStore.projectStore.get(task.projectId), + this.tasks[task.projectId], + // @ts-ignore + this.tasks[DEFAULT_PROJECT_ID.MyDay], + pathToNode + ); + } + + remove(task: TaskModel) { function condition(_task: TaskModel) { return _task.key === task.key; } - this.stopTimer(); + if (task.active) { + this.stopTimer(); + } for (const projectKey in this.tasks) { if (this.tasks.hasOwnProperty(projectKey)) { @@ -107,13 +133,13 @@ export default class TaskStore { ); } } - this.tasksService.save(this.tasks); + this.saveInStorage(); GaService.event(EEventCategory.Tasks, ETasksEvents.Delete); } - deleteProjectTasks(projectKey: string) { + removeProjectTasks(projectKey: string) { delete this.tasks[projectKey]; - this.tasksService.save(this.tasks); + this.saveInStorage(); } startTimer(task: TaskModel) { @@ -121,18 +147,17 @@ export default class TaskStore { this.activeTask = task; task.start(); this.setupReminder(task); - this.tasksService.save(this.tasks); + this.saveInStorage(); } stopTimer(silent?: boolean) { if (this.activeTask) { this.activeTask.stop(); - this.activeTask = undefined; } if (!silent) { this.setupReminder(); - this.tasksService.save(this.tasks); + this.saveInStorage(); } } @@ -166,24 +191,26 @@ export default class TaskStore { checkTaskFn ); - this.set(projectId, this.tasks[projectId].slice()); + this.saveInStorage(); } GaService.event(EEventCategory.Tasks, ETasksEvents.Check); } markExpanded(projectId: string, taskIds: string[]) { - const markExpanded = (task: TaskModel, taskIds: string[]) => { - task.expanded = taskIds.includes(task.key); + const markExpanded = (task: Task, taskIds: string[]) => { + if (task instanceof TaskModel) { + task.expanded = taskIds.includes(task.key); + } }; if (Array.isArray(this.tasks[projectId])) { - TreeModelHelper.modifyItemsWithIdsRecursive( + TreeModelHelper.modifyItemsWithIdsRecursive( this.tasks[projectId], taskIds, markExpanded ); - this.set(projectId, this.tasks[projectId].slice()); + this.saveInStorage(); } } @@ -251,4 +278,8 @@ export default class TaskStore { clearInterval(this.interval); } } + + private updateVersion() { + this.versionHash = uuid(); + } } diff --git a/src/modules/tasks/models/TaskInMyDay.ts b/src/modules/tasks/models/TaskInMyDay.ts new file mode 100644 index 0000000..4a346d1 --- /dev/null +++ b/src/modules/tasks/models/TaskInMyDay.ts @@ -0,0 +1,36 @@ +import TaskModel from './TaskModel'; + +export class TaskInMyDay extends TaskModel { + origin: TaskModel | null = null; + children?: TaskInMyDay[] = []; + + constructor(originTaskModel: TaskModel, children: TaskInMyDay[]) { + super(originTaskModel); + this.origin = originTaskModel; + this.children = children; + } +} + +export const taskModelProxyHandler: ProxyHandler = { + get(target: TaskInMyDay, prop: string | symbol): any { + return target?.[prop as keyof TaskInMyDay]; + }, + set(target: TaskInMyDay, prop: string | symbol, value: any): boolean { + if (prop === 'duration') { + console.error( + `TaskModel: Can't set prop '${prop.toString()}' in`, + target + ); + return false; + } + // @ts-ignore + target[prop] = value; + + if (!['expanded', 'children'].includes(prop as string)) { + // @ts-ignore + target.origin[prop] = value; + } + + return true; + }, +}; diff --git a/src/modules/tasks/models/TaskModel.ts b/src/modules/tasks/models/TaskModel.ts index 4334e66..723f3fa 100644 --- a/src/modules/tasks/models/TaskModel.ts +++ b/src/modules/tasks/models/TaskModel.ts @@ -1,9 +1,8 @@ import { action, computed, makeObservable, observable } from 'mobx'; -import isSameDay from 'date-fns/isSameDay'; +import { isSameDay, startOfDay } from 'date-fns'; import AbstractModel from '../../../base/AbstractModel'; -import { ITreeItem } from '../../../types/ITreeItem'; -import { startOfDay } from 'date-fns'; +import { ITreeItemWithParent } from '../../../types/ITreeItem'; export interface IJsonTimeRangeModel { start: string; @@ -17,65 +16,83 @@ export interface ITimeRangeModel { description?: string; } -interface IJsonTaskModel extends ITreeItem { - projectId: string; - checked: boolean; - active: boolean; - expanded: boolean; - time: string[][] | IJsonTimeRangeModel[]; - datesInProgress: string[]; - children: IJsonTaskModel[]; - details: string[]; +export interface IJsonTaskModel extends ITreeItemWithParent { + children?: IJsonTaskModel[]; + projectId?: string; + checked?: boolean; + active?: boolean; + expanded?: boolean; + inMyDay?: string; + time?: string[][] | IJsonTimeRangeModel[]; + datesInProgress?: string[]; + details?: string[]; } -export default class TaskModel extends AbstractModel { +const parseTimeRageItems = ( + timeItems: (string[] | IJsonTimeRangeModel | ITimeRangeModel)[] +) => { + return timeItems.map( + (range: string[] | IJsonTimeRangeModel | ITimeRangeModel) => { + if (Array.isArray(range)) { + return { + start: new Date(range[0]), + end: range[1] ? new Date(range[1]) : undefined, + description: undefined, + }; + } else { + return { + start: new Date(range.start), + end: range.end ? new Date(range.end) : undefined, + description: range.description, + }; + } + } + ); +}; + +export default class TaskModel extends AbstractModel + implements ITreeItemWithParent { key: string = ''; title: string = ''; - children: TaskModel[] = []; + children?: TaskModel[] = []; + parent: TaskModel | undefined = undefined; projectId: string = ''; checked: boolean = false; - expanded: boolean = true; active: boolean = false; + expanded: boolean = true; + inMyDay: Date | null = null; time: ITimeRangeModel[] = []; datesInProgress: Date[] = []; details: string = ''; + withoutActions: boolean = false; // TODO make a new class - constructor(props: IJsonTaskModel) { + constructor(props: IJsonTaskModel | TaskModel) { super(); - this.load(props); - this.children = props.children?.map((json) => new TaskModel(json)) || []; - this.time = - // @ts-ignore - props.time?.map( - (range: string[] | IJsonTimeRangeModel) => { - if (Array.isArray(range)) { - return { - start: new Date(range[0]), - end: range[1] ? new Date(range[1]) : undefined, - description: undefined, - }; - } else { - return { - start: new Date(range.start), - end: range.end ? new Date(range.end) : undefined, - description: range.description, - }; - } - } - ) || []; - this.datesInProgress = - props.datesInProgress?.map((date) => new Date(date)) || []; + const newProps = { + ...props, + children: props.children?.map((json) => new TaskModel(json)) || [], + time: props.time ? parseTimeRageItems(props.time) : [], + datesInProgress: + props.datesInProgress?.map((date) => new Date(date)) || [], + inMyDay: props?.inMyDay ? new Date(props?.inMyDay) : null, + }; + + this.load(newProps); makeObservable(this, { key: observable, title: observable, children: observable, + // parent: none projectId: observable, checked: observable, active: observable, + expanded: observable, + inMyDay: observable, time: observable, datesInProgress: observable, details: observable, + withoutActions: observable, duration: computed, setTitle: action, setDetails: action, @@ -117,9 +134,10 @@ export default class TaskModel extends AbstractModel { start() { this.active = true; - this.addDateWhenWasInProgress(new Date()); + const dateNow = new Date(); + this.addDateWhenWasInProgress(dateNow); this.time.push({ - start: new Date(), + start: dateNow, end: undefined, description: undefined, }); @@ -141,7 +159,7 @@ export default class TaskModel extends AbstractModel { const normalDate = startOfDay(date); const found = this.datesInProgress.find((d) => isSameDay(d, normalDate)); if (!found) { - this.datesInProgress.push(normalDate); + this.datesInProgress = [...this.datesInProgress, normalDate]; } } } diff --git a/src/modules/tasks/models/TaskWithProjectNameModel.ts b/src/modules/tasks/models/TaskWithProjectNameModel.ts new file mode 100644 index 0000000..eaedb15 --- /dev/null +++ b/src/modules/tasks/models/TaskWithProjectNameModel.ts @@ -0,0 +1,23 @@ +import AbstractModel from '../../../base/AbstractModel'; +import { ITreeItemWithParent } from '../../../types/ITreeItem'; +import TaskModel from './TaskModel'; + +interface ITaskWithProjectName { + key: string; + title: string; + parent: null; + children: TaskModel[]; +} + +export class TaskWithProjectNameModel extends AbstractModel + implements ITreeItemWithParent { + key: string = ''; + title: string = ''; + parent: TaskModel | undefined = undefined; + children?: TaskModel[] = []; + + constructor(props: ITaskWithProjectName) { + super(); + this.load(props); + } +} diff --git a/src/modules/tasks/models/TasksByProject.ts b/src/modules/tasks/models/TasksByProject.ts index f7cae9d..4c140c2 100644 --- a/src/modules/tasks/models/TasksByProject.ts +++ b/src/modules/tasks/models/TasksByProject.ts @@ -1,5 +1,5 @@ import TaskModel from './TaskModel'; -type TasksByProject = Record; - -export default TasksByProject; +// export type Task = TaskModel | TaskInMyDay | TaskWithProjectNameModel; +export type Task = TaskModel; +export type TasksByProject = Record; diff --git a/src/package.json b/src/package.json index 1e54e98..54f950f 100644 --- a/src/package.json +++ b/src/package.json @@ -1,7 +1,7 @@ { "name": "time-tracker", "productName": "TimeTracker", - "version": "1.0.3", + "version": "1.0.10", "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 1e4426f..7cc471d 100644 --- a/src/screens/Main.tsx +++ b/src/screens/Main.tsx @@ -1,16 +1,12 @@ import React, { useEffect } from 'react'; -import { Route, Switch, Link, Redirect, useLocation } from 'react-router-dom'; +import { Route, Switch, Redirect, useLocation } from 'react-router-dom'; import { Layout } from 'antd'; import ProjectsScreen from './projects/ProjectsScreen'; -import TaskControl from '../components/TaskControl/TaskControl'; -import HeaderMenu from '../components/HeaderMenu/HeaderMenu'; import HoursScreen from './hours/HoursScreen'; import Dashboard from './dashboard/Dashboard'; -import Profile from '../components/Profile/Profile'; import GaService from '../services/gaService/GaService'; - -const { Header } = Layout; +import Header from '../components/Header'; const Main = () => { const location = useLocation(); @@ -25,20 +21,7 @@ const Main = () => { return ( -
- - Hours - - - Projects - - - Dashboard - - - - -
+
diff --git a/src/screens/dashboard/Dashboard.tsx b/src/screens/dashboard/Dashboard.tsx index 1789a39..7cb6471 100644 --- a/src/screens/dashboard/Dashboard.tsx +++ b/src/screens/dashboard/Dashboard.tsx @@ -3,7 +3,7 @@ import { createUseStyles } from 'react-jss'; import { Layout, Space } from 'antd'; import { observer } from 'mobx-react'; -import SelectDate from '../../components/SelectDate/SelectDate'; +import SelectDate from '../../components/SelectDate'; import rootStore from '../../modules/RootStore'; import { getTasksWithTotalTimeForDay } from '../../helpers/TaskHelper'; import HoursWithDuration from './components/HoursWithDuration'; diff --git a/src/screens/hours/HoursScreen.tsx b/src/screens/hours/HoursScreen.tsx index fa46421..39110da 100644 --- a/src/screens/hours/HoursScreen.tsx +++ b/src/screens/hours/HoursScreen.tsx @@ -5,7 +5,7 @@ import { observer } from 'mobx-react'; import rootStore from '../../modules/RootStore'; import HoursCard from './components/HoursCard/HoursCard'; import { getTimeItems } from '../../helpers/TaskHelper'; -import SelectDate from '../../components/SelectDate/SelectDate'; +import SelectDate from '../../components/SelectDate'; import TimeRangeModal from '../../components/TimeRangeModal/TimeRangeModal'; import TaskTimeItemModel from '../../modules/tasks/models/TaskTimeItemModel'; import { Undefined } from '../../types/CommonTypes'; diff --git a/src/screens/hours/components/HoursCard/HoursCard.tsx b/src/screens/hours/components/HoursCard/HoursCard.tsx index d5c9f54..82851ba 100644 --- a/src/screens/hours/components/HoursCard/HoursCard.tsx +++ b/src/screens/hours/components/HoursCard/HoursCard.tsx @@ -1,24 +1,16 @@ import React, { useMemo } from 'react'; import { Card } from 'antd'; -import format from 'date-fns/format'; import { observer } from 'mobx-react'; import { createUseStyles } from 'react-jss'; import TaskTimeItemModel from '../../../../modules/tasks/models/TaskTimeItemModel'; -import PlayStopButton from '../../../../components/PlayStopButton/PlayStopButton'; +import PlayStopButton from '../../../../components/PlayStopButton'; import rootStore from '../../../../modules/RootStore'; -import { msToTime } from '../../../../helpers/DateTime'; +import { msToTime, toTimeFormat } from '../../../../helpers/DateTime'; +import { getTaskTitlesPath } from '../../../../helpers/TaskHelper'; const { projectStore } = rootStore; -const formatStr = 'HH:mm'; -function timeFormat(date: Date | undefined) { - if (date) { - return format(date, formatStr); - } - return ''; -} - interface HoursCardProps { taskTime: TaskTimeItemModel; onClick: (taskTime: TaskTimeItemModel) => void; @@ -31,9 +23,11 @@ export default observer(function HoursCard({ const { task, time } = taskTime; const classes = useStyle(); const project = useMemo(() => projectStore.get(task.projectId), [task]); - const duration = time.end - ? msToTime(time.end.getTime() - time.start.getTime()) - : ''; + const duration = useMemo( + () => (time.end ? msToTime(time.end.getTime() - time.start.getTime()) : ''), + [time.end, time.start] + ); + const titlePath = useMemo(() => getTaskTitlesPath(task), [task]); return (
{project?.title}
+
{titlePath}
{task.title}
{time.description}
-
{`${timeFormat( - time.start - )} - ${timeFormat(time.end)}`}
+
+ {`${toTimeFormat(time.start)} - ${toTimeFormat(time.end)}`} +
{duration}
@@ -80,6 +75,9 @@ const useStyle = createUseStyles({ projectTitle: { fontSize: 12, }, + taskTitlePath: { + fontSize: 12, + }, taskTitle: { fontWeight: 'bold', fontSize: 12, diff --git a/src/screens/hours/components/TotalHours/TotalHours.tsx b/src/screens/hours/components/TotalHours/TotalHours.tsx index 9954df8..48b182e 100644 --- a/src/screens/hours/components/TotalHours/TotalHours.tsx +++ b/src/screens/hours/components/TotalHours/TotalHours.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { observer } from 'mobx-react'; import { Space } from 'antd'; @@ -6,11 +6,13 @@ import * as TaskHooks from '../../../../hooks/TaskHooks'; import TaskTimeItemModel from '../../../../modules/tasks/models/TaskTimeItemModel'; import { estimateWorkingTimeEnd, - getTime, + toTimeFormat, msToTime, + getRestHoursMs, } from '../../../../helpers/DateTime'; import LabelWithTooltip, { ILabelWithTooltipProps } from './LabelWithTooltip'; import rootStore from '../../../../modules/RootStore'; +import { getStartWorkingTime } from '../../../../helpers/TaskHelper'; interface TotalHoursProps { timeItems: TaskTimeItemModel[]; @@ -24,44 +26,49 @@ const TotalHours = observer((props: TotalHoursProps) => { const workingHoursMs = settings.numberOfWorkingHours; const { durationMs, restMs } = TaskHooks.useTimeItemsDuration(timeItems); - const startWorkingTime = TaskHooks.useStartWorkingTime(timeItems); + const startWorkingTime = useMemo(() => getStartWorkingTime(timeItems), [ + timeItems, + ]); const estimatedWorkingTimeEnd = estimateWorkingTimeEnd( startWorkingTime, restMs, workingHoursMs ); - const restHoursMs = workingHoursMs - durationMs; + const restHoursMs = getRestHoursMs(workingHoursMs, durationMs); + + const items: ILabelWithTooltipProps[] = useMemo( + () => [ + { + label: toTimeFormat(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: toTimeFormat(estimatedWorkingTimeEnd), + tooltip: 'Estimated end time', + }, + { + label: msToTime(restHoursMs, false), + tooltip: 'Time left', + }, + ], + [startWorkingTime, durationMs, restMs, estimatedWorkingTimeEnd, restHoursMs] + ); 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 time', - }, - { - label: msToTime(restHoursMs, false), - tooltip: 'Time left', - }, - ]; - return ( {items.map((props, index) => ( diff --git a/src/screens/projects/ProjectsScreen.tsx b/src/screens/projects/ProjectsScreen.tsx index d7a1fd1..7e39651 100644 --- a/src/screens/projects/ProjectsScreen.tsx +++ b/src/screens/projects/ProjectsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Button, Layout, Space } from 'antd'; import { observer } from 'mobx-react'; import { Key } from 'rc-tree/lib/interface'; @@ -9,12 +9,15 @@ import TaskInput from './components/TaskInput'; import rootStore from '../../modules/RootStore'; import TreeList from './components/TreeList'; import TaskModel from '../../modules/tasks/models/TaskModel'; -import ProjectModel from '../../modules/projects/models/ProjectModel'; +import ProjectModel, { + DEFAULT_PROJECT_ID, +} from '../../modules/projects/models/ProjectModel'; import ProjectModal from './components/ProjectModals/ProjectModal'; 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 { first } from '../../helpers/ArrayHelper'; const { Sider } = Layout; @@ -27,6 +30,9 @@ const TaskList = TreeList( }, { checkable: true, + isDraggable() { + return projectStore.activeProject !== DEFAULT_PROJECT_ID.MyDay; + }, onExpand(keys: Key[]) { tasksStore.markExpanded(projectStore.activeProject, keys as string[]); }, @@ -45,13 +51,14 @@ const TaskList = TreeList( } ); -const ProjectList = TreeList( +const ProjectList = TreeList( () => projectStore.projects, (list: ProjectModel[]) => { projectStore.set(list); }, { selectable: false, + draggable: true, titleRender(project: ProjectModel) { return ( 0) { + projectStore.setActiveProject(first(items) as string); + } +} + +function clearEditableProject() { + projectStore.setEditableProject(undefined); +} + +function Projects() { + const style = useStyles(); const [showProjectModal, setShowProjectModal] = useState(false); const [drawerVisible, setDrawerVisible] = useState(false); const [selectedTask, setSelectedTask] = useState(); @@ -79,24 +96,26 @@ export default observer(function Projects() { setShowProjectModal(true); } - function handleSelectProject(items: Key[]) { - if (items.length > 0) { - projectStore.setActiveProject(items[0] as string); - } - } - - function handleSelectTask(items: Key[]) { + const handleSelectTask = useCallback((items: Key[]) => { if (items.length > 0) { setDrawerVisible(true); - const task = tasksStore.getTaskByKey(items[0] as string); + const task = tasksStore.getTaskByKey(first(items) as string); setSelectedTask(task); } - } + }, []); + + const handleCloseDrawer = useCallback(() => { + setDrawerVisible(false); + }, []); + + const handleHideProjectModal = useCallback(() => { + setShowProjectModal(false); + }, []); return ( - - + +