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 @@
+
+
+
+
+
+
+
# 🕘 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)*
+
+
+## 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.
+
+
+
## 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 (
-
-
+
+
}>
@@ -105,34 +124,52 @@ export default observer(function Projects() {
-
-
+
+
- {showProjectModal && (
- setShowProjectModal(false)} />
- )}
+ {showProjectModal && }
projectStore.setEditableProject(undefined)}
+ onClose={clearEditableProject}
/>
setDrawerVisible(false)}
+ onClose={handleCloseDrawer}
/>
);
-});
+}
+
+export default observer(Projects);
const useStyles = createUseStyles({
sider: {
backgroundColor: '#f0f2f5',
borderRight: '1px solid #d9d9d9',
},
- tasks: {
+ taskList: {
overflowY: 'auto',
+ padding: '12px 12px 0 12px',
+ },
+ padding: {
+ padding: 12,
+ },
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ height: '100%',
+ },
+ stickyTaskInput: {
+ position: 'sticky',
+ bottom: 0,
+ padding: '12px 0',
+ backgroundColor: '#f0f2f5',
},
});
diff --git a/src/screens/projects/components/DrawerTask/DrawerTask.tsx b/src/screens/projects/components/DrawerTask/DrawerTask.tsx
index 206ff30..87ba370 100644
--- a/src/screens/projects/components/DrawerTask/DrawerTask.tsx
+++ b/src/screens/projects/components/DrawerTask/DrawerTask.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useState } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
import { Checkbox, Drawer, Input, Space } from 'antd';
import { observer } from 'mobx-react';
import { ProjectOutlined } from '@ant-design/icons';
@@ -7,12 +7,13 @@ import { createUseStyles } from 'react-jss';
import TaskModel from '../../../../modules/tasks/models/TaskModel';
import rootStore from '../../../../modules/RootStore';
import HoursByTask from '../HoursByTask/HoursByTask';
-import IconTile from '../../../../components/IconTile/IconTile';
+import IconTile from '../../../../components/IconTile';
import Duration from './components/Duration';
import TimeRangeModal from '../../../../components/TimeRangeModal/TimeRangeModal';
import { Undefined } from '../../../../types/CommonTypes';
import TaskTimeItemModel from '../../../../modules/tasks/models/TaskTimeItemModel';
import IModalProps from '../../../../types/IModalProps';
+import { PURPLE_COLOR } from '../../../../consts';
const { TextArea } = Input;
@@ -36,6 +37,22 @@ export default observer(function DrawerTask({
task,
]);
+ const handleTitleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const title = e.target.value;
+ task?.setTitle(title);
+ },
+ [task]
+ );
+
+ const handleDetailsChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const details = e.target.value;
+ task?.setDetails(details);
+ },
+ [task]
+ );
+
return (
- {
- const title = e.target.value;
- if (task) {
- task.setTitle(title);
- }
- }}
+ onChange={handleTitleChange}
/>