diff --git a/.github/workflows/webpack.yml b/.github/workflows/webpack.yml index 0291cfa..ba06062 100644 --- a/.github/workflows/webpack.yml +++ b/.github/workflows/webpack.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest name: Lint code steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 23 @@ -27,10 +27,10 @@ jobs: runs-on: ubuntu-latest name: Run tests steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 23 @@ -44,10 +44,10 @@ jobs: name: Build production needs: [lint, test] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 23 diff --git a/.gitignore b/.gitignore index bd9c006..c95c57c 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,4 @@ dist .vscode *storybook.log +.DS_Store diff --git a/.husky/pre-commit b/.husky/pre-commit index a7faed1..acd8ec2 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,6 +1,3 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') [ -z "$FILES" ] && exit 0 diff --git a/.prettierrc b/.prettierrc index d52622e..b810b2b 100644 --- a/.prettierrc +++ b/.prettierrc @@ -10,6 +10,10 @@ "importOrder": [ "", "^@shared/(.*)$", + "^@popup/(.*)$", + "^@background/(.*)$", + "^@content/(.*)$", + "^@dashboard/(.*)$", "^[./]" ], "importOrderSeparation": true diff --git a/.storybook/main.ts b/.storybook/main.ts index 048d8f4..79f5f99 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -6,12 +6,19 @@ const config: StorybookConfig = { '@storybook/addon-webpack5-compiler-swc', '@storybook/addon-essentials', '@storybook/addon-styling-webpack', + '@storybook/addon-themes', ], framework: { name: '@storybook/react-webpack5', options: {}, }, - webpackFinal: async (config) => { + env(config) { + return { + ...config, + ENV: 'storybook', + }; + }, + webpackFinal: (config) => { // eslint-disable-next-line @typescript-eslint/no-require-imports const path = require('path'); // eslint-disable-next-line @typescript-eslint/no-require-imports diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 17a3463..1ad4b36 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -2,6 +2,8 @@ import type { Preview } from '@storybook/react'; import '!style-loader!css-loader!postcss-loader!../src/tailwind.css'; +import { withThemeByClassName } from "@storybook/addon-themes"; + const preview: Preview = { parameters: { controls: { @@ -11,6 +13,15 @@ const preview: Preview = { }, }, }, + + decorators: [withThemeByClassName({ + themes: { + // nameOfTheme: 'classNameForTheme', + light: '', + dark: 'dark', + }, + defaultTheme: 'light', + })] }; export default preview; diff --git a/eslint.config.mjs b/eslint.config.mjs index 021c803..25ff970 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -55,8 +55,7 @@ export default [ 'react/prop-types': 'off', 'react/display-name': 'off', 'no-unused-vars': 'off', - '@typescript-eslint/no-explicit-any': 'off', - + '@typescript-eslint/no-explicit-any': ['warn', { ignoreRestArgs: true }], '@typescript-eslint/no-unused-vars': [ 'error', { diff --git a/package-lock.json b/package-lock.json index 5c2eeae..a212063 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,16 @@ "version": "0.7.3", "license": "GPL3", "dependencies": { - "chart.js": "3.2.1", + "chart.js": "^4.4.6", "idb": "6.1.2", "react": "17.0.2", - "react-chartjs-2": "3.0.3", + "react-chartjs-2": "^5.2.0", "react-dom": "17.0.2", "react-github-contribution-calendar": "2.2.0", - "react-tooltip": "4.2.21", + "react-tooltip": "5.28.0", "tailwind-merge": "2.5.4", - "throttle-debounce": "5.0.2" + "throttle-debounce": "5.0.2", + "utility-types": "^3.11.0" }, "devDependencies": { "@chromatic-com/storybook": "^3.2.2", @@ -28,6 +29,7 @@ "@storybook/addon-interactions": "^8.4.0", "@storybook/addon-onboarding": "^8.4.0", "@storybook/addon-styling-webpack": "^1.0.1", + "@storybook/addon-themes": "^8.4.1", "@storybook/addon-webpack5-compiler-swc": "^1.0.5", "@storybook/blocks": "^8.4.0", "@storybook/react": "^8.4.0", @@ -46,20 +48,20 @@ "@types/throttle-debounce": "^2.1.0", "@typescript-eslint/eslint-plugin": "8.12.2", "@typescript-eslint/parser": "8.12.2", - "autoprefixer": "^10.4.12", + "autoprefixer": "^10.4.20", "copy-webpack-plugin": "^8.1.1", "css-loader": "7.1.2", "eslint": "9.13.0", "eslint-plugin-react": "7.37.2", "eslint-plugin-react-hooks": "5.0.0", "eslint-plugin-storybook": "^0.10.1", - "husky": "^8.0.0", + "husky": "9.1.6", "jest": "29.7.0", "mocha": "10.8.2", "postcss": "8.4.47", "postcss-loader": "8.1.1", "prettier": "3.3.3", - "storybook": "^8.4.0", + "storybook": "8.4.2", "style-loader": "4.0.0", "tailwindcss": "3.4.14", "ts-jest": "29.2.5", @@ -67,7 +69,7 @@ "ts-node": "^10.9.2", "typescript": "5.6.3", "web-ext-types": "^3.2.1", - "webpack": "5.95.0", + "webpack": "5.96.1", "webpack-cli": "5.1.4" } }, @@ -1640,6 +1642,31 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2300,6 +2327,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==", + "license": "MIT" + }, "node_modules/@mdx-js/react": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz", @@ -2606,6 +2639,23 @@ "webpack": "^5.0.0" } }, + "node_modules/@storybook/addon-themes": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-8.4.1.tgz", + "integrity": "sha512-yfJ0NbXdLGGM0dUSJNPtSvHznTFSfsyAEJwpslJfqJ9q03Z/mMct8SfXesg7VFLNP0Uxgv+KG4+E0+Dcjv/V1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.4.1" + } + }, "node_modules/@storybook/addon-toolbars": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.4.1.tgz", @@ -2794,9 +2844,9 @@ } }, "node_modules/@storybook/core": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.4.1.tgz", - "integrity": "sha512-q3Q4OFBj7MHHbIFYk/Beejlqv5j7CC3+VWhGcr0TK3SGvdCIZ7EliYuc5JIOgDlEPsnTIk+lkgWI4LAA9mLzSw==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.4.2.tgz", + "integrity": "sha512-hF8GWoUZTjwwuV5j4OLhMHZtZQL/NYcVUBReC2Ba06c8PkFIKqKZwATr1zKd301gQ5Qwcn9WgmZxJTMgdKQtOg==", "dev": true, "license": "MIT", "dependencies": { @@ -3600,6 +3650,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -4409,16 +4481,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -5268,10 +5330,16 @@ } }, "node_modules/chart.js": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.2.1.tgz", - "integrity": "sha512-XsNDf3854RGZkLCt+5vWAXGAtUdKP2nhfikLGZqud6G4CvRE2ts64TIxTTfspOin2kEZvPgomE29E6oU02dYjQ==", - "license": "MIT" + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.6.tgz", + "integrity": "sha512-8Y406zevUPbbIBA/HRk33khEmQPk5+cxeflWE/2rx1NJsjVWMPw/9mSP9rxHP5eqi6LNoPBVMfZHxbwLSgldYA==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } }, "node_modules/check-error": { "version": "2.1.1", @@ -5365,6 +5433,12 @@ "dev": true, "license": "MIT" }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -7610,16 +7684,16 @@ } }, "node_modules/husky": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", - "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.6.tgz", + "integrity": "sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A==", "dev": true, "license": "MIT", "bin": { - "husky": "lib/bin.js" + "husky": "bin.js" }, "engines": { - "node": ">=14" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/typicode" @@ -9571,6 +9645,7 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, "license": "MIT" }, "node_modules/lodash.memoize": { @@ -11152,16 +11227,13 @@ } }, "node_modules/react-chartjs-2": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-3.0.3.tgz", - "integrity": "sha512-jOFZKwZ8sMLkddewZ/tToxuu4pYimAvvY5I6uK+hCpSFT16Pvo2bdHhUoZ0X87zu9I+dx2I+JCqaLN6XhmrbDg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", "license": "MIT", - "dependencies": { - "lodash": "^4.17.19" - }, "peerDependencies": { - "chart.js": "^3.1.0", - "react": "^16.8.0 || ^17.0.0" + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "node_modules/react-confetti": { @@ -11290,29 +11362,17 @@ "license": "MIT" }, "node_modules/react-tooltip": { - "version": "4.2.21", - "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-4.2.21.tgz", - "integrity": "sha512-zSLprMymBDowknr0KVDiJ05IjZn9mQhhg4PRsqln0OZtURAJ1snt1xi5daZfagsh6vfsziZrc9pErPTDY1ACig==", + "version": "5.28.0", + "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.28.0.tgz", + "integrity": "sha512-R5cO3JPPXk6FRbBHMO0rI9nkUG/JKfalBSQfZedZYzmqaZQgq7GLzF8vcCWx6IhUCKg0yPqJhXIzmIO5ff15xg==", "license": "MIT", "dependencies": { - "prop-types": "^15.7.2", - "uuid": "^7.0.3" - }, - "engines": { - "npm": ">=6.13" + "@floating-ui/dom": "^1.6.1", + "classnames": "^2.3.0" }, "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" - } - }, - "node_modules/react-tooltip/node_modules/uuid": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", - "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + "react": ">=16.14.0", + "react-dom": ">=16.14.0" } }, "node_modules/read-cache": { @@ -11923,13 +11983,13 @@ } }, "node_modules/storybook": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.4.1.tgz", - "integrity": "sha512-0tfFIFghjho9FtnFoiJMoxhcs2iIdvEF81GTSVnTsDVJrYA84nB+FxN3UY1fT0BcQ8BFlbf+OhSjZL7ufqqWKA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.4.2.tgz", + "integrity": "sha512-GMCgyAulmLNrkUtDkCpFO4SB77YrpiIxq6e5tzaQdXEuaDu1mdNwOuP3VG7nE2FzxmqDvagSgriM68YW9iFaZA==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/core": "8.4.1" + "@storybook/core": "8.4.2" }, "bin": { "getstorybook": "bin/index.cjs", @@ -13149,6 +13209,15 @@ "dev": true, "license": "MIT" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -13217,19 +13286,19 @@ "license": "MPL-2.0" }, "node_modules/webpack": { - "version": "5.95.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.95.0.tgz", - "integrity": "sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q==", + "version": "5.96.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.96.1.tgz", + "integrity": "sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.5", + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.6", "@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1", "@webassemblyjs/wasm-parser": "^1.12.1", - "acorn": "^8.7.1", - "acorn-import-attributes": "^1.9.5", - "browserslist": "^4.21.10", + "acorn": "^8.14.0", + "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", diff --git a/package.json b/package.json index f0ee3c3..dc7b7bf 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@storybook/addon-interactions": "^8.4.0", "@storybook/addon-onboarding": "^8.4.0", "@storybook/addon-styling-webpack": "^1.0.1", + "@storybook/addon-themes": "^8.4.1", "@storybook/addon-webpack5-compiler-swc": "^1.0.5", "@storybook/blocks": "^8.4.0", "@storybook/react": "^8.4.0", @@ -70,20 +71,20 @@ "@types/throttle-debounce": "^2.1.0", "@typescript-eslint/eslint-plugin": "8.12.2", "@typescript-eslint/parser": "8.12.2", - "autoprefixer": "^10.4.12", + "autoprefixer": "10.4.20", "copy-webpack-plugin": "^8.1.1", "css-loader": "7.1.2", "eslint": "9.13.0", "eslint-plugin-react": "7.37.2", "eslint-plugin-react-hooks": "5.0.0", "eslint-plugin-storybook": "^0.10.1", - "husky": "^8.0.0", + "husky": "9.1.6", "jest": "29.7.0", "mocha": "10.8.2", "postcss": "8.4.47", "postcss-loader": "8.1.1", "prettier": "3.3.3", - "storybook": "^8.4.0", + "storybook": "8.4.2", "style-loader": "4.0.0", "tailwindcss": "3.4.14", "ts-jest": "29.2.5", @@ -91,19 +92,20 @@ "ts-node": "^10.9.2", "typescript": "5.6.3", "web-ext-types": "^3.2.1", - "webpack": "5.95.0", + "webpack": "5.96.1", "webpack-cli": "5.1.4" }, "dependencies": { - "chart.js": "3.2.1", + "chart.js": "^4.4.6", "idb": "6.1.2", "react": "17.0.2", - "react-chartjs-2": "3.0.3", + "react-chartjs-2": "^5.2.0", "react-dom": "17.0.2", "react-github-contribution-calendar": "2.2.0", - "react-tooltip": "4.2.21", + "react-tooltip": "5.28.0", "tailwind-merge": "2.5.4", - "throttle-debounce": "5.0.2" + "throttle-debounce": "5.0.2", + "utility-types": "^3.11.0" }, "eslintConfig": { "extends": [ diff --git a/src/background/browser-api/badge.ts b/src/background/browser-api/badge.ts index 28953e0..428551e 100644 --- a/src/background/browser-api/badge.ts +++ b/src/background/browser-api/badge.ts @@ -4,7 +4,7 @@ import { ignore } from '@shared/utils/errors'; export const setActionBadge = async ({ tabId, text, - color = '#4b76e3', + color, }: { tabId: number; text: string; diff --git a/src/background/controller/active.ts b/src/background/controller/active.ts deleted file mode 100644 index 4c7859c..0000000 --- a/src/background/controller/active.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { TimelineRecord } from '@shared/db/types'; -import { Tab } from '@shared/services/browser-api/types'; -import { getActiveTabRecord, setActiveTabRecord } from '@shared/tables/state'; -import { getIsoDate } from '@shared/utils/date'; -import { getHostNameFromUrl } from '@shared/utils/url'; - -export class ActiveTimelineRecordDao { - private record: null | Promise = null; - - public get(): Promise { - if (!this.record) { - this.record = getActiveTabRecord(); - } - - return this.record; - } - - public async set(record: TimelineRecord) { - this.record = Promise.resolve(record); - - await setActiveTabRecord(record); - } - - public async isExist() { - const record = await this.get(); - return !!record; - } -} - -export async function createNewActiveRecord( - timestamp: number, - focusedActiveTab: Tab, -) { - if (!focusedActiveTab.id) { - return; - } - const date = getIsoDate(new Date(timestamp)); - const { url = '', title = '' } = focusedActiveTab; - const hostname = getHostNameFromUrl(url); - - await setActiveTabRecord({ - tabId: focusedActiveTab.id, - url, - hostname, - docTitle: title, - date, - activityPeriodStart: timestamp, - activityPeriodEnd: timestamp, - }); -} diff --git a/src/background/controller/index.ts b/src/background/controller/index.ts index 61d4bc7..228f8be 100644 --- a/src/background/controller/index.ts +++ b/src/background/controller/index.ts @@ -1,10 +1,13 @@ +import { DeepReadonly, Mutable } from 'utility-types'; + import { ActiveTabState, TimelineRecord } from '@shared/db/types'; import { getSettings } from '@shared/preferences'; -import { setActiveTabRecord } from '@shared/tables/state'; +import { Tab } from '@shared/services/browser-api/types'; +import { getActiveTabRecord, setActiveTabRecord } from '@shared/tables/state'; +import { HostName, IsoDate } from '@shared/types'; import { getIsoDate, getMinutesInMs } from '@shared/utils/date'; -import { isInvalidUrl } from '@shared/utils/url'; +import { getHostNameFromUrl, isInvalidUrl } from '@shared/utils/url'; -import { ActiveTimelineRecordDao, createNewActiveRecord } from './active'; import { updateTimeOnBadge } from './badge'; import { updateDomainInfo } from './domain-info'; import { handlePageLimitExceed } from './limits'; @@ -13,12 +16,13 @@ import { saveTimelineRecord } from './timeline'; const FIVE_MINUTES = getMinutesInMs(5); export const handleStateChange = async ( - activeTabState: ActiveTabState, + activeTabState: DeepReadonly, timestamp: number = Date.now(), ) => { - const preferences = await getSettings(); - const activeTimeline = new ActiveTimelineRecordDao(); - const currentTimelineRecord = await activeTimeline.get(); + const [preferences, currentTimelineRecord] = await Promise.all([ + getSettings(), + getActiveTabRecord(), + ]); const focusedActiveTab = activeTabState.focusedActiveTab ?? null; const isLocked = activeTabState.idleState === 'locked'; @@ -33,11 +37,14 @@ export const handleStateChange = async ( const isImpossiblyLongEvent = timestamp - lastHeartbeatTs > FIVE_MINUTES; if (currentTimelineRecord) { - currentTimelineRecord.activityPeriodEnd = isImpossiblyLongEvent + const updatedTimelineRecord = structuredClone( + currentTimelineRecord, + ) as Mutable; + updatedTimelineRecord.activityPeriodEnd = isImpossiblyLongEvent ? currentTimelineRecord.activityPeriodEnd : timestamp; - await activeTimeline.set(currentTimelineRecord); + await setActiveTabRecord(updatedTimelineRecord); } const isDomainIgnored = preferences.ignoredHosts.includes( @@ -96,12 +103,33 @@ async function commitTabActivity(currentTimelineRecord: TimelineRecord | null) { // previous day's total time as well. // Dates in the array should be different in this case. const dates = Array.from( - new Set([currentIsoDate, currentTimelineRecord.date]), + new Set([currentIsoDate, currentTimelineRecord.date as IsoDate]), ); await Promise.all( - dates.map((date) => updateTotalTime(date, currentTimelineRecord.hostname)), + dates.map((date) => + updateTotalTime(date, currentTimelineRecord.hostname as HostName), + ), ); await setActiveTabRecord(null); } + +async function createNewActiveRecord(timestamp: number, focusedActiveTab: Tab) { + if (!focusedActiveTab.id) { + return; + } + const date = getIsoDate(new Date(timestamp)); + const { url = '', title = '' } = focusedActiveTab; + const hostname = getHostNameFromUrl(url); + + await setActiveTabRecord({ + tabId: focusedActiveTab.id, + url, + hostname, + docTitle: title, + date, + activityPeriodStart: timestamp, + activityPeriodEnd: timestamp, + }); +} diff --git a/src/background/controller/overall.ts b/src/background/controller/overall.ts index f2f8a96..bfc4012 100644 --- a/src/background/controller/overall.ts +++ b/src/background/controller/overall.ts @@ -1,9 +1,10 @@ import { setTotalDailyHostTime } from '@shared/db/sync-storage'; import { getActivityTimeline } from '@shared/tables/activity-timeline'; +import { HostName, IsoDate } from '@shared/types'; export async function updateTotalTime( - currentIsoDate: string, - hostname: string, + currentIsoDate: IsoDate, + hostname: HostName, ) { const timeline = await getActivityTimeline(currentIsoDate); const timeOnRecord = timeline diff --git a/src/background/services/state-service.ts b/src/background/services/state-service.ts index a06dd42..024e98f 100644 --- a/src/background/services/state-service.ts +++ b/src/background/services/state-service.ts @@ -1,3 +1,5 @@ +import { DeepReadonly } from 'utility-types'; + import { ActiveTabState } from '@shared/db/types'; import { isUserDraggingWindowError } from '@shared/services/browser-api/errors'; import type { @@ -97,7 +99,7 @@ export const handleTabUpdate = async (tab: Tab) => { export const handleWindowFocusChange = async ( windowId: number, -): Promise => { +): Promise> => { try { const focusedActiveTab = await getActiveTabFromWindowId(windowId); diff --git a/src/popup/App.css b/src/popup/App.css index badfb16..244f2a7 100644 --- a/src/popup/App.css +++ b/src/popup/App.css @@ -24,11 +24,6 @@ body { filter: invert(1); } -.calendar svg rect { - cursor: pointer; - rx: 3; -} - i.fi::before { line-height: inherit; } diff --git a/src/popup/App.tsx b/src/popup/App.tsx index 7a3f8dc..374c6cf 100644 --- a/src/popup/App.tsx +++ b/src/popup/App.tsx @@ -3,13 +3,15 @@ import { twMerge } from 'tailwind-merge'; import { Icon, IconType } from '@shared/blocks/Icon'; import { Panel } from '@shared/blocks/Panel'; +import { IsoDate } from '@shared/types'; + +import { PopupContextProvider } from '@popup/hooks/PopupContext'; +import { ActivityPage } from '@popup/pages/activity/ActivityPage'; +import { OverallPage } from '@popup/pages/overall/OverallPage'; +import { PreferencesPage } from '@popup/pages/preferences/PreferencesPage'; import '../tailwind.css'; import './App.css'; -import { PopupContextProvider } from './hooks/PopupContext'; -import { ActivityPage } from './pages/ActivityPage'; -import { OverallPage } from './pages/OverallPage'; -import { PreferencesPage } from './pages/PreferencesPage'; enum Pages { Overall = 'overall', @@ -22,17 +24,20 @@ const PAGES_VALUES = Object.values(Pages); export const PopupApp: React.FC = () => { const [activePage, setPage] = React.useState({ tab: Pages.Overall, - params: {} as Record, + params: {} as Record, }); - const handleNavigateToActivityDatePage = React.useCallback((date: string) => { - setPage({ - tab: Pages.Detailed, - params: { - date, - }, - }); - }, []); + const handleNavigateToActivityDatePage = React.useCallback( + (date: IsoDate) => { + setPage({ + tab: Pages.Detailed, + params: { + date, + }, + }); + }, + [], + ); const renderedActiveTab = React.useMemo(() => { switch (activePage.tab) { diff --git a/src/popup/pages/activity/WebsiteActivityTable.tsx b/src/popup/components/ActivityTable.tsx similarity index 53% rename from src/popup/pages/activity/WebsiteActivityTable.tsx rename to src/popup/components/ActivityTable.tsx index f60dd8f..0419f38 100644 --- a/src/popup/pages/activity/WebsiteActivityTable.tsx +++ b/src/popup/components/ActivityTable.tsx @@ -2,63 +2,42 @@ import * as React from 'react'; import { Icon, IconType } from '@shared/blocks/Icon'; import { Panel, PanelBody, PanelHeader } from '@shared/blocks/Panel'; -import { selectHostnames } from '@shared/tables/domain-info'; +import { ActivitySummaryByHostname } from '@shared/db/types'; +import { i18n } from '@shared/services/i18n'; import { getTimeFromMs, getTimeWithoutSeconds } from '@shared/utils/date'; -import { usePopupContext } from '../../hooks/PopupContext'; - -type Domain = string; -type Time = number; +import { + ActivitySummaryByDate, + calculateTotalActivity, +} from '@popup/services/time-store'; export interface ActivityTableProps { - websiteTimeMap: Record; - title?: string; + activity: ActivitySummaryByHostname | ActivitySummaryByDate; + title: string; + faviconMap?: Map; onDomainRowClicked?: (domain: string) => void; - onFilterButtonClicked?: (domain: string) => void; - onUndoFilterButtonClicked?: (domain: string) => void; + onFilterDomainButtonClicked?: (domain: string) => void; + onUndoFilterDomainButtonClicked?: (domain: string) => void; } -const DEFAULT_TITLE = 'Websites This Day'; - -const WebsiteActivityTableFC: React.FC = ({ - websiteTimeMap: activity, - title = DEFAULT_TITLE, +export const ActivityTable: React.FC = ({ + activity, + title, + faviconMap = new Map(), onDomainRowClicked, + onFilterDomainButtonClicked, }) => { - const { settings, updateSettings } = usePopupContext(); const totalActivity = React.useMemo( - () => Object.values(activity ?? {}).reduce((acc, val) => acc + val, 0) || 0, + () => calculateTotalActivity(activity), [activity], ); - const websiteSortedDesc = React.useMemo( + const websiteEntriesSortedDesc = React.useMemo( () => - Object.entries(activity ?? {}).sort( - ([, timeA], [, timeB]) => timeB - timeA, - ), + Object.entries(activity).sort(([, timeA], [, timeB]) => timeB - timeA), [activity], ); - const [domainFavIconMap, setDomainFavIconMap] = React.useState< - Record - >({}); - - React.useEffect(() => { - selectHostnames(websiteSortedDesc.map(([domain]) => domain)).then( - (domainInfo) => { - setDomainFavIconMap( - domainInfo.reduce( - (acc, { hostname, iconUrl }) => { - acc[hostname] = iconUrl; - return acc; - }, - {} as Record, - ), - ); - }, - ); - }, [websiteSortedDesc]); - const handleDomainRowClick = React.useCallback( (domain: string) => onDomainRowClicked?.(domain), [onDomainRowClicked], @@ -66,11 +45,9 @@ const WebsiteActivityTableFC: React.FC = ({ const handleHideDomainClick = React.useCallback( (domain: string) => { - updateSettings({ - ignoredHosts: Array.from(new Set([...settings.ignoredHosts, domain])), - }); + onFilterDomainButtonClicked?.(domain); }, - [settings.ignoredHosts, updateSettings], + [onFilterDomainButtonClicked], ); return ( @@ -83,7 +60,13 @@ const WebsiteActivityTableFC: React.FC = ({ {getTimeWithoutSeconds(totalActivity)} - {websiteSortedDesc.map(([domain, time]) => { + {websiteEntriesSortedDesc.length === 0 ? ( + + {i18n('ActivityTable_NoDataAvailable')} + + ) : null} + + {websiteEntriesSortedDesc.map(([domain, time]) => { return (
= ({ title={domain} onClick={() => handleDomainRowClick(domain)} > - {domainFavIconMap[domain] ? ( + {faviconMap.has(domain) ? ( {domain ) : ( )} @@ -113,7 +96,7 @@ const WebsiteActivityTableFC: React.FC = ({ handleHideDomainClick(domain)} />
@@ -121,19 +104,15 @@ const WebsiteActivityTableFC: React.FC = ({ })}

- Click on the website name to view stats for this website. + {i18n('ActivityTable_ClickToFilterHint')}

- Click on the icon to - hide and ignore this website. You can always unhide it in the - Ignored domains section. + {i18n('ActivityTable_ClickToIgnoreHintPart1')}{' '} + {' '} + {i18n('ActivityTable_ClickToIgnoreHintPart2')}

); }; - -export const WebsiteActivityTable: React.FC = React.memo( - WebsiteActivityTableFC, -); diff --git a/src/popup/components/ActivityTimelineChart.tsx b/src/popup/components/ActivityTimelineChart.tsx new file mode 100644 index 0000000..e96675b --- /dev/null +++ b/src/popup/components/ActivityTimelineChart.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; + +import { Icon, IconType } from '@shared/blocks/Icon'; +import { Panel, PanelBody, PanelHeader } from '@shared/blocks/Panel'; +import { TimelineChart } from '@shared/components/TimelineChart'; +import { TimelineRecord } from '@shared/db/types'; +import { i18n } from '@shared/services/i18n'; + +export interface ActivityTimelineChartProps { + title: string; + emptyHoursMarginCount?: number; + activityTimeline: TimelineRecord[]; + isDarkMode?: boolean; +} + +const ActivityTimelineChartFC: React.FC = ({ + title, + activityTimeline, + emptyHoursMarginCount = 2, + isDarkMode, +}) => { + return ( + + + + {title} + + + {activityTimeline.length ? ( + + ) : ( + + {i18n('ActivityTimelineChart_NoData')} + + )} + + + ); +}; + +export const ActivityTimelineChart: React.FC = + React.memo(ActivityTimelineChartFC); diff --git a/src/popup/pages/activity/ActivityDatePicker.tsx b/src/popup/components/DatePicker.tsx similarity index 81% rename from src/popup/pages/activity/ActivityDatePicker.tsx rename to src/popup/components/DatePicker.tsx index 2fa093a..d5c75f5 100644 --- a/src/popup/pages/activity/ActivityDatePicker.tsx +++ b/src/popup/components/DatePicker.tsx @@ -2,18 +2,17 @@ import * as React from 'react'; import { Button, ButtonType } from '@shared/blocks/Button'; import { Icon, IconType } from '@shared/blocks/Icon'; -import { getDaysInMs, getIsoDate } from '@shared/utils/date'; +import { IsoDate } from '@shared/types'; +import { assertIsIsoDate, getDaysInMs, getIsoDate } from '@shared/utils/date'; -interface ActivityDatePickerProps { - date: string; - onChange: (date: string) => void; +const DAY_IN_MS = getDaysInMs(1); + +interface DatePickerProps { + date: IsoDate; + onChange: (date: IsoDate) => void; } -const DAY_IN_MS = getDaysInMs(1); -export const ActivityDatePicker: React.FC = ({ - date, - onChange, -}) => { +export const DatePicker: React.FC = ({ date, onChange }) => { const onDateChangeButtonClick = React.useCallback( (direction: 1 | -1) => { const selectedDate = new Date(date); @@ -28,6 +27,8 @@ export const ActivityDatePicker: React.FC = ({ const handleChangeToSpecificDate = React.useCallback( (event: React.ChangeEvent): void => { + assertIsIsoDate(event.currentTarget.value); + onChange(event.currentTarget.value); }, [onChange], diff --git a/src/popup/components/GeneralTimeline.tsx b/src/popup/components/GeneralTimeline.tsx deleted file mode 100644 index bcfed99..0000000 --- a/src/popup/components/GeneralTimeline.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from 'react'; -import { twMerge } from 'tailwind-merge'; - -import { Icon, IconType } from '@shared/blocks/Icon'; -import { Panel, PanelBody, PanelHeader } from '@shared/blocks/Panel'; -import { TimelineChart } from '@shared/components/TimelineChart'; -import { TimelineRecord } from '@shared/db/types'; - -export interface GeneralTimelineProps { - title: string; - emptyHoursMarginCount?: number; - filteredHostname?: string | null; - activityTimeline: TimelineRecord[]; - isDarkMode?: boolean; -} - -const GeneralTimelineFC: React.FC = ({ - filteredHostname, - activityTimeline, - title, - emptyHoursMarginCount = 2, - isDarkMode, -}) => { - return ( - - - - {title} - {filteredHostname ? ` On ${filteredHostname}` : ''} - - 0 && 'opacity-100', - )} - > - - - Don't have timeline data for this day - - - - ); -}; - -export const GeneralTimeline: React.FC = - React.memo(GeneralTimelineFC); diff --git a/src/popup/components/TimeUsagePanel.tsx b/src/popup/components/TimeUsagePanel.tsx index 6e11ef3..49face9 100644 --- a/src/popup/components/TimeUsagePanel.tsx +++ b/src/popup/components/TimeUsagePanel.tsx @@ -2,33 +2,32 @@ import * as React from 'react'; import { Icon, IconType } from '@shared/blocks/Icon'; import { Panel, PanelBody, PanelHeader } from '@shared/blocks/Panel'; +import { i18n } from '@shared/services/i18n'; import { getMinutesInMs, getTimeWithoutSeconds } from '@shared/utils/date'; export interface TimeUsagePanelProps { - title?: string; + title: string; averageTime?: number; - averageTimeComparedTo?: string; - time: number; + totalActivityTime: number; } -const COMPONENT_TITLE = 'Daily Usage'; const MINUTE_IN_MS = getMinutesInMs(1); -const LOWER_SPAN = lower; -const HIGHER_SPAN = higher; +const LOWER_SPAN = ( + {i18n('TimeUsagePanel_LowerSpan')} +); +const HIGHER_SPAN = ( + {i18n('TimeUsagePanel_HigherSpan')} +); -const presentWeekComparison = ( - time: number, - averageTime: number, - averageTimeComparedTo: string, -) => { +const presentWeekComparison = (time: number, averageTime: number) => { const percent = Math.round((time / averageTime) * 100 - 100); return ( {Math.abs(percent)} % {percent < 0 ? LOWER_SPAN : HIGHER_SPAN} - than {averageTimeComparedTo} + {' ' + i18n('TimeUsagePanel_ThanAverageSpan')} ); }; @@ -42,9 +41,8 @@ const presentTotalDailyActivity = (totalDailyActivity: number) => { }; export const TimeUsagePanel: React.FC = ({ - title = COMPONENT_TITLE, - averageTimeComparedTo = 'average', - time, + title, + totalActivityTime: time, averageTime = 0, }) => { return ( @@ -58,9 +56,11 @@ export const TimeUsagePanel: React.FC = ({ {averageTime > 0 && ( - 7 days average: {presentTotalDailyActivity(averageTime)} + {i18n('TimeUsagePanel_7DayAverageTime', { + time: presentTotalDailyActivity(averageTime), + })} - {presentWeekComparison(time, averageTime, averageTimeComparedTo)} + {presentWeekComparison(time, averageTime)} )} diff --git a/src/popup/pages/activity/WeekDatePicker.tsx b/src/popup/components/WeekDatePicker.tsx similarity index 90% rename from src/popup/pages/activity/WeekDatePicker.tsx rename to src/popup/components/WeekDatePicker.tsx index 2970945..be008e2 100644 --- a/src/popup/pages/activity/WeekDatePicker.tsx +++ b/src/popup/components/WeekDatePicker.tsx @@ -13,8 +13,11 @@ export const WeekDatePicker: React.FC = ({ onWeekChange, sundayDate, }) => { - const weekStartDate = new Date(); - weekStartDate.setDate(sundayDate.getDate() - 6); + const weekStartDate = React.useMemo(() => { + const date = new Date(); + date.setDate(sundayDate.getDate() - 6); + return date; + }, [sundayDate]); const handleChangeWeekButtonClick = React.useCallback( (direction) => { diff --git a/src/popup/components/_stories/ActivityTable.stories.tsx b/src/popup/components/_stories/ActivityTable.stories.tsx new file mode 100644 index 0000000..0064449 --- /dev/null +++ b/src/popup/components/_stories/ActivityTable.stories.tsx @@ -0,0 +1,53 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { ActivitySummaryByHostname } from '@shared/db/types'; + +import { ActivityTable } from '../ActivityTable'; + +export default { + title: 'Popup/components/ActivityTable', + component: ActivityTable, + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +type Story = StoryObj>; + +export const Default: Story = { + render: () => ( + alert(`Clicked ${hostname}`)} + onFilterDomainButtonClicked={(hostname) => alert(`Filter ${hostname}`)} + activity={ + { + 'example.com': 120000, + 'example.org': 45000, + 'example.net': 30000, + 'example.a': 30000, + 'example.v': 30000, + 'example.c': 30000, + } as unknown as ActivitySummaryByHostname + } + title="Custom Title" + /> + ), + parameters: { + layout: 'centered', + }, +}; + +export const Empty: Story = { + render: () => ( + alert(`Clicked ${hostname}`)} + onFilterDomainButtonClicked={(hostname) => alert(`Filter ${hostname}`)} + activity={{} as ActivitySummaryByHostname} + title="Custom Title" + /> + ), + parameters: { + layout: 'centered', + }, +}; diff --git a/src/popup/components/_stories/ActivityTimelineChart.stories.tsx b/src/popup/components/_stories/ActivityTimelineChart.stories.tsx new file mode 100644 index 0000000..1c6f36f --- /dev/null +++ b/src/popup/components/_stories/ActivityTimelineChart.stories.tsx @@ -0,0 +1,46 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { getIsoDate } from '@shared/utils/date'; + +import { ActivityTimelineChart } from '../ActivityTimelineChart'; + +export default { + title: 'popup/components/ActivityTimelineChart', + component: ActivityTimelineChart, + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +type Story = StoryObj>; + +export const EmptyTimeline: Story = { + render: () => ( + + ), +}; + +export const Default: Story = { + render: () => ( + + ), +}; diff --git a/src/popup/components/_stories/DatePicker.stories.tsx b/src/popup/components/_stories/DatePicker.stories.tsx new file mode 100644 index 0000000..962220f --- /dev/null +++ b/src/popup/components/_stories/DatePicker.stories.tsx @@ -0,0 +1,22 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { getIsoDate } from '@shared/utils/date'; + +import { DatePicker } from '../DatePicker'; + +export default { + title: 'Popup/components/DatePicker', + component: DatePicker, + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +type Story = StoryObj>; + +const TODAY = getIsoDate(new Date()); + +export const Default: Story = { + render: () => alert(date)} date={TODAY} />, +}; diff --git a/src/popup/components/_stories/TimeUsagePanel.stories.tsx b/src/popup/components/_stories/TimeUsagePanel.stories.tsx new file mode 100644 index 0000000..40f7402 --- /dev/null +++ b/src/popup/components/_stories/TimeUsagePanel.stories.tsx @@ -0,0 +1,50 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { getIsoDate } from '@shared/utils/date'; + +import { TimeUsagePanel } from '../TimeUsagePanel'; + +export default { + title: 'popup/components/TimeUsagePanel', + component: TimeUsagePanel, + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +type Story = StoryObj>; + +export const EmptyTimeline: Story = { + render: () => ( +
+ +
+ ), +}; + +export const Default: Story = { + render: () => ( +
+ +
+ ), +}; + +export const DefaultWithAverageComparison: Story = { + render: () => ( +
+ +
+ ), + parameters: { + layout: 'centered', + }, +}; diff --git a/src/popup/components/_stories/WeekDatePicker.stories.tsx b/src/popup/components/_stories/WeekDatePicker.stories.tsx new file mode 100644 index 0000000..e92d293 --- /dev/null +++ b/src/popup/components/_stories/WeekDatePicker.stories.tsx @@ -0,0 +1,27 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { getDatesWeekSundayDate } from '@shared/utils/date'; + +import { WeekDatePicker } from '../WeekDatePicker'; + +export default { + title: 'Popup/components/WeekDatePicker', + component: WeekDatePicker, + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +type Story = StoryObj>; + +const SUNDAY_DATE = getDatesWeekSundayDate(new Date()); + +export const Default: Story = { + render: () => ( + alert(date)} + sundayDate={SUNDAY_DATE} + /> + ), +}; diff --git a/src/popup/hooks/PopupContext.tsx b/src/popup/hooks/PopupContext.tsx index cdbf79b..9556f30 100644 --- a/src/popup/hooks/PopupContext.tsx +++ b/src/popup/hooks/PopupContext.tsx @@ -1,8 +1,11 @@ import * as React from 'react'; +import { DeepReadonly } from 'utility-types'; import { Preferences } from '@shared/db/types'; import { DEFAULT_PREFERENCES } from '@shared/preferences'; +import { getFilteredWebsiteTimeStoreSlice } from '@popup/services/time-store'; + import { useActiveTabHostname } from './useActiveTab'; import { useSettings } from './useSettings'; import { TimeStore, useTimeStore } from './useTimeStore'; @@ -10,7 +13,7 @@ import { TimeStore, useTimeStore } from './useTimeStore'; export type PopupContextType = { store: TimeStore; activeHostname: string; - settings: Preferences; + settings: DeepReadonly; updateSettings: (updated: Partial) => void; }; @@ -18,7 +21,7 @@ const DEFAULT_CONTEXT: PopupContextType = { store: {}, activeHostname: '', settings: DEFAULT_PREFERENCES, - updateSettings: () => 0, + updateSettings: () => {}, }; const PopupContext = React.createContext(DEFAULT_CONTEXT); @@ -30,27 +33,13 @@ export const PopupContextProvider: React.FC = ({ children }) => { const host = useActiveTabHostname(); const [settings, updateSettings] = useSettings(); - const filterDomainsFromStore = React.useCallback( - (store: Record) => { - const filteredStore = Object.fromEntries( - Object.entries(store).filter( - ([key]) => !settings.ignoredHosts.includes(key), - ), - ); - return filteredStore; - }, - [settings.ignoredHosts], - ); - const filteredStore = React.useMemo( () => - Object.fromEntries( - Object.entries(store).map(([day, value]) => [ - day, - filterDomainsFromStore(value), - ]), + getFilteredWebsiteTimeStoreSlice( + store, + (_date, host) => !settings.ignoredHosts.includes(host), ), - [store, filterDomainsFromStore], + [store, settings.ignoredHosts], ); return ( diff --git a/src/popup/hooks/useActiveTabTime.ts b/src/popup/hooks/useActiveTabTime.ts index 8f608b0..4e4981c 100644 --- a/src/popup/hooks/useActiveTabTime.ts +++ b/src/popup/hooks/useActiveTabTime.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { get7DaysPriorDate, getIsoDate } from '@shared/utils/date'; +import { generatePrior7DaysDates, getIsoDate } from '@shared/utils/date'; import { usePopupContext } from './PopupContext'; @@ -9,8 +9,10 @@ export const useActiveTabTime = () => { return useMemo(() => { const today = new Date(); + // @ts-expect-error -- Fix hostname types const time = store?.[getIsoDate(today)]?.[activeHostname] ?? 0; - const weekTime = get7DaysPriorDate(today).reduce((sum, date) => { + const weekTime = generatePrior7DaysDates(today).reduce((sum, date) => { + // @ts-expect-error -- Fix hostname types return sum + (store?.[getIsoDate(date)]?.[activeHostname] ?? 0); }, 0); diff --git a/src/popup/hooks/useSettings.ts b/src/popup/hooks/useSettings.ts index 9dd1133..36640f8 100644 --- a/src/popup/hooks/useSettings.ts +++ b/src/popup/hooks/useSettings.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import { DeepReadonly } from 'utility-types'; import { Preferences } from '@shared/db/types'; import { @@ -9,7 +10,7 @@ import { export const useSettings = () => { const [settings, setCachedSettings] = - React.useState(DEFAULT_PREFERENCES); + React.useState>(DEFAULT_PREFERENCES); React.useEffect(() => { getSettings().then(setCachedSettings); @@ -18,7 +19,7 @@ export const useSettings = () => { const updateSettings = React.useCallback( async (updates: Partial) => { await setSettings(updates); - setCachedSettings((set) => ({ ...set, ...updates })); + setCachedSettings((prev) => ({ ...prev, ...updates })); }, [], ); diff --git a/src/popup/hooks/useTimeStore.ts b/src/popup/hooks/useTimeStore.ts index 3945181..f0b5c47 100644 --- a/src/popup/hooks/useTimeStore.ts +++ b/src/popup/hooks/useTimeStore.ts @@ -3,18 +3,19 @@ import * as React from 'react'; import { getTotalActivity } from '@shared/db/sync-storage'; import type { TimeStore } from '@shared/db/types'; import { getActiveTabRecord } from '@shared/tables/state'; +import { HostName } from '@shared/types'; import { getIsoDate } from '@shared/utils/date'; export { TimeStore }; export const useTimeStore = () => { - const [store, setStore] = React.useState({} as TimeStore); + const [store, setStore] = React.useState({}); React.useEffect(() => { Promise.all([getTotalActivity(), getActiveTabRecord()]).then( ([activity, activeRecord]) => { - const hostname = activeRecord?.hostname; - if (hostname) { + if (activeRecord?.hostname) { + const hostname = activeRecord?.hostname as HostName; const date = getIsoDate(new Date()); const currentDayActivity = (activity[date] ??= {}); currentDayActivity[hostname] ??= 0; diff --git a/src/popup/pages/OverallPage.tsx b/src/popup/pages/OverallPage.tsx deleted file mode 100644 index 9d39263..0000000 --- a/src/popup/pages/OverallPage.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import * as React from 'react'; - -import { getMinutesInMs } from '@shared/utils/date'; - -import { GeneralTimeline } from '../components/GeneralTimeline'; -import { TimeUsagePanel } from '../components/TimeUsagePanel'; -import { usePopupContext } from '../hooks/PopupContext'; -import { useActiveTabTime } from '../hooks/useActiveTabTime'; -import { useLastSixHoursTimelineEvents } from '../hooks/useLastSixHoursTimeline'; -import { useIsDarkMode } from '../hooks/useTheme'; -import { useTotalWebsiteActivity } from '../hooks/useTotalWebsiteActivity'; -import { OverallActivityCalendarPanel } from './overall/OverallActivityCalendar'; - -export interface OverallPageProps { - onNavigateToActivityPage: React.ComponentProps< - typeof OverallActivityCalendarPanel - >['navigateToDateActivityPage']; -} - -const MINUTE_IN_MS = getMinutesInMs(1); - -export const OverallPage: React.FC = ({ - onNavigateToActivityPage, -}) => { - const { store, activeHostname } = usePopupContext(); - const { todaysUsage, weeklyUsage } = useTotalWebsiteActivity(store); - const timelineEvents = useLastSixHoursTimelineEvents(); - const { time: activeWebsiteTime, weekTime: activeWebsiteWeekTime } = - useActiveTabTime(); - const isDarkMode = useIsDarkMode(); - - return ( -
- {todaysUsage > MINUTE_IN_MS ? ( - - ) : null} - {activeWebsiteTime ? ( - - ) : null} - - -
- ); -}; diff --git a/src/popup/pages/_stories/ActivityPage.stories.tsx b/src/popup/pages/_stories/ActivityPage.stories.tsx new file mode 100644 index 0000000..1939a97 --- /dev/null +++ b/src/popup/pages/_stories/ActivityPage.stories.tsx @@ -0,0 +1,21 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { ActivityPage } from '@popup/pages/activity/ActivityPage'; + +export default { + title: 'popup/pages/ActivityPage', + component: ActivityPage, + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +type Story = StoryObj>; + +export const Default: Story = { + render: () => , + parameters: { + layout: 'centered', + }, +}; diff --git a/src/popup/pages/_stories/OverallPage.stories.tsx b/src/popup/pages/_stories/OverallPage.stories.tsx new file mode 100644 index 0000000..2858f11 --- /dev/null +++ b/src/popup/pages/_stories/OverallPage.stories.tsx @@ -0,0 +1,25 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { OverallPage } from '@popup/pages/overall/OverallPage'; + +export default { + title: 'popup/pages/OverallPage', + component: OverallPage, + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +type Story = StoryObj>; + +export const Default: Story = { + render: () => ( + alert(`Navigate to ${date}`)} + /> + ), + parameters: { + layout: 'centered', + }, +}; diff --git a/src/popup/pages/_stories/PreferencesPage.stories.tsx b/src/popup/pages/_stories/PreferencesPage.stories.tsx new file mode 100644 index 0000000..b504b9e --- /dev/null +++ b/src/popup/pages/_stories/PreferencesPage.stories.tsx @@ -0,0 +1,21 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { PreferencesPage } from '@popup/pages/preferences/PreferencesPage'; + +export default { + title: 'popup/pages/PreferencesPage', + component: PreferencesPage, + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +type Story = StoryObj>; + +export const Default: Story = { + render: () => , + parameters: { + layout: 'centered', + }, +}; diff --git a/src/popup/pages/ActivityPage.tsx b/src/popup/pages/activity/ActivityPage.tsx similarity index 74% rename from src/popup/pages/ActivityPage.tsx rename to src/popup/pages/activity/ActivityPage.tsx index 73cdb0a..584abee 100644 --- a/src/popup/pages/ActivityPage.tsx +++ b/src/popup/pages/activity/ActivityPage.tsx @@ -2,16 +2,18 @@ import * as React from 'react'; import { Button, ButtonType } from '@shared/blocks/Button'; import { Panel } from '@shared/blocks/Panel'; +import { IsoDate } from '@shared/types'; import { getDatesWeekSundayDate, getIsoDate } from '@shared/utils/date'; -import { usePopupContext } from '../hooks/PopupContext'; -import { ActivityDatePicker } from './activity/ActivityDatePicker'; -import { DailyActivityTab } from './activity/ActivityPageDailyActivityTab'; -import { ActivityPageWeeklyActivityTab } from './activity/ActivityPageWeeklyActivityTab'; -import { WeekDatePicker } from './activity/WeekDatePicker'; +import { DatePicker } from '@popup/components/DatePicker'; +import { WeekDatePicker } from '@popup/components/WeekDatePicker'; +import { usePopupContext } from '@popup/hooks/PopupContext'; + +import { DailyActivityTab } from './components/DailyActivityTab'; +import { WeeklyActivityTab } from './components/WeeklyActivityTab'; interface ActivityPageProps { - date?: string; + date?: IsoDate; } enum ActivityPageTabs { @@ -57,10 +59,7 @@ export const ActivityPage: React.FC = ({
{tabs}
{activeTab === ActivityPageTabs.Daily && ( - + )} {activeTab === ActivityPageTabs.Weekly && ( = ({ )} {activeTab === ActivityPageTabs.Weekly && ( - + )} ); diff --git a/src/popup/pages/activity/ActivityPageDailyActivityTab.tsx b/src/popup/pages/activity/ActivityPageDailyActivityTab.tsx deleted file mode 100644 index 8e0bdf2..0000000 --- a/src/popup/pages/activity/ActivityPageDailyActivityTab.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import * as React from 'react'; - -import { TimelineRecord } from '@shared/db/types'; -import { getActivityTimeline } from '@shared/tables/activity-timeline'; -import { getTotalDailyActivity } from '@shared/utils/time-store'; - -import { GeneralTimeline } from '../../components/GeneralTimeline'; -import { useIsDarkMode } from '../../hooks/useTheme'; -import { TimeStore } from '../../hooks/useTimeStore'; -import { useTotalWebsiteActivity } from '../../hooks/useTotalWebsiteActivity'; -import { DailyUsage } from './DailyUsage'; -import { WebsiteActivityTable } from './WebsiteActivityTable'; - -export interface DailyActivityTabProps { - store: TimeStore; - date: string; -} - -const useActivityTimeline = (date: string, filteredHostname?: string) => { - const [activityTimeline, setActivityTimeline] = React.useState< - TimelineRecord[] - >([]); - - React.useEffect(() => { - (async () => { - const timeline = await getActivityTimeline(date); - setActivityTimeline( - filteredHostname - ? timeline.filter((record) => record.hostname === filteredHostname) - : timeline, - ); - })(); - }, [date, filteredHostname]); - - return activityTimeline; -}; - -export const DailyActivityTab: React.FC = ({ - store, - date, -}) => { - const [filteredHostname, setFilteredHostname] = React.useState< - string | undefined - >(undefined); - const dailyActiveWebsites = React.useMemo( - () => store[date] ?? {}, - [store, date], - ); - const totalDailyActivity = React.useMemo( - () => getTotalDailyActivity(store, new Date(date)), - [store, date], - ); - const { weeklyUsage } = useTotalWebsiteActivity(store); - const activityTimeline = useActivityTimeline(date, filteredHostname); - - const scrollToRef = React.useRef(null); - - const handleDomainRowClick = React.useCallback((domain: string) => { - setFilteredHostname(domain); - scrollToRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, []); - - const isDarkMode = useIsDarkMode(); - - return ( - <> - -
- -
- - - ); -}; diff --git a/src/popup/pages/activity/ActivityPageWeeklyActivityTab.tsx b/src/popup/pages/activity/ActivityPageWeeklyActivityTab.tsx deleted file mode 100644 index 178ada7..0000000 --- a/src/popup/pages/activity/ActivityPageWeeklyActivityTab.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import * as React from 'react'; - -import { get7DaysPriorDate, getIsoDate } from '@shared/utils/date'; -import { getTotalWeeklyActivity } from '@shared/utils/time-store'; - -import { TimeUsagePanel } from '../../components/TimeUsagePanel'; -import { TimeStore } from '../../hooks/useTimeStore'; -import { WebsiteActivityTable } from './WebsiteActivityTable'; -import { WeeklyWebsiteActivityChart } from './WeeklyWebsiteActivityChart'; - -export interface ActivityPageWeeklyActivityTabProps { - store: TimeStore; - sundayDate: Date; -} - -export const ActivityPageWeeklyActivityTab: React.FC< - ActivityPageWeeklyActivityTabProps -> = ({ store, sundayDate }) => { - const [pickedDomain, setPickedDomain] = React.useState(null); - const scrollToRef = React.useRef(null); - - const handleDomainRowClick = React.useCallback((domain: string) => { - setPickedDomain(domain); - scrollToRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, []); - - const allWeekActivity = React.useMemo( - () => - get7DaysPriorDate(sundayDate).reduce((acc, date) => { - const isoDate = getIsoDate(date); - acc[isoDate] = store[isoDate] || {}; - - return acc; - }, {} as TimeStore), - [store, sundayDate], - ); - - const filteredWebsiteWeekActivity = React.useMemo(() => { - if (pickedDomain === null) { - return allWeekActivity; - } - - return Object.entries(allWeekActivity).reduce( - (acc, [date, dateWebsitesUsage]) => { - acc[date] = { - [pickedDomain]: dateWebsitesUsage[pickedDomain] || 0, - }; - - return acc; - }, - {} as typeof allWeekActivity, - ); - }, [allWeekActivity, pickedDomain]); - - const totalWebsiteWeeklyActivity = React.useMemo( - () => - Object.values(allWeekActivity).reduce( - (acc, dailyUsage) => { - Object.entries(dailyUsage).forEach(([key, value]) => { - acc[key] ??= 0; - acc[key] += value; - }); - - return acc; - }, - {} as Record, - ), - [allWeekActivity], - ); - - const averageWeeklyActivity = React.useMemo(() => { - const averageWeekly = - getTotalWeeklyActivity(filteredWebsiteWeekActivity, sundayDate) / 7; - return averageWeekly; - }, [filteredWebsiteWeekActivity, sundayDate]); - - const presentedPickedDomain = pickedDomain ?? 'All Websites'; - - return ( -
- -
- - `Activity on ${presentedPickedDomain} per day` - } - /> -
- -
- ); -}; diff --git a/src/popup/pages/activity/DailyUsage.tsx b/src/popup/pages/activity/DailyUsage.tsx deleted file mode 100644 index 58c8359..0000000 --- a/src/popup/pages/activity/DailyUsage.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from 'react'; - -import { Icon, IconType } from '@shared/blocks/Icon'; -import { Panel, PanelHeader } from '@shared/blocks/Panel'; -import { getMinutesInMs } from '@shared/utils/date'; - -import { TimeUsagePanel } from '../../components/TimeUsagePanel'; -import { DailyUsageChart } from './DailyUsageChart'; - -export interface DailyUsageProps { - date: string; - totalDailyActivity: number; - weeklyAverage: number; - dailyActivity: Record; -} - -const MINUTE_IN_MS = getMinutesInMs(1); - -export const DailyUsageFC: React.FC = ({ - date, - dailyActivity, - weeklyAverage, - totalDailyActivity, -}) => { - return ( -
- - - - - Top 5 Active Sites on {date} - - {totalDailyActivity > MINUTE_IN_MS ? ( -
- -
- ) : ( -
- Nothing to see here yet... -
- )} -
-
- ); -}; - -export const DailyUsage: React.FC = React.memo(DailyUsageFC); diff --git a/src/popup/pages/activity/DailyUsageChart.tsx b/src/popup/pages/activity/DailyUsageChart.tsx deleted file mode 100644 index 55d0c0a..0000000 --- a/src/popup/pages/activity/DailyUsageChart.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import * as React from 'react'; -import { Doughnut } from 'react-chartjs-2'; - -import { getMinutesInMs, getTimeWithoutSeconds } from '@shared/utils/date'; - -import { useIsDarkMode } from '../../hooks/useTheme'; - -export interface DailyUsageChartProps { - date: string; - activity: Record; - totalDailyActivity: number; -} - -const DOUGHNUT_CHART_OPTIONS = { - responsive: true, - plugins: { - legend: { - position: 'left', - labels: { - color: '#222', - }, - }, - tooltip: { - callbacks: { - title: ([item]: any) => { - return `${item?.label}`; - }, - label: (item: any) => { - return ` ${item.formattedValue}%`; - }, - }, - }, - }, -}; - -const DARK_MODE_DOUGHNUT_CHART_OPTIONS = { - ...DOUGHNUT_CHART_OPTIONS, - plugins: { - ...DOUGHNUT_CHART_OPTIONS.plugins, - legend: { - ...DOUGHNUT_CHART_OPTIONS.plugins.legend, - labels: { - ...DOUGHNUT_CHART_OPTIONS.plugins.legend.labels, - color: '#e5e5e5', - }, - }, - }, -}; - -const ITEMS_TO_DISPLAY = 5; -const ITEMS_COLORS = [ - '#ffa600', - '#f97144', - '#d44d63', - '#9a3f70', - '#5a3764', - '#262944', -]; -const ONE_MINUTE = getMinutesInMs(1); - -const presentChartLabel = (key: string, value: number) => { - const timeString = value > ONE_MINUTE ? getTimeWithoutSeconds(value) : '<1m'; - const label = `${key} (${timeString})`; - - return label; -}; - -const buildChartDataFromActivity = ({ - date, - activity, - totalDailyActivity, -}: DailyUsageChartProps) => { - const entriesByDesc = Object.entries(activity).sort( - ([, value1], [, value2]) => { - return value2 - value1; - }, - ); - - const itemsToDisplay = entriesByDesc.splice(0, ITEMS_TO_DISPLAY); - if (entriesByDesc.length > 0) { - const restActivityTime = entriesByDesc.reduce( - (acc, [_, value]) => acc + value, - 0, - ); - - itemsToDisplay.push(['Other pages', restActivityTime]); - } - - const labels = itemsToDisplay.map(([key, value]) => - presentChartLabel(key, value), - ); - const data = itemsToDisplay.map(([_, value]) => - Math.floor((value / totalDailyActivity) * 100), - ); - - return { - labels, - datasets: [ - { - label: date, - data, - backgroundColor: ITEMS_COLORS, - }, - ], - }; -}; - -export const DailyUsageChart: React.FC = ({ - activity, - date, - totalDailyActivity, -}) => { - const isDarkMode = useIsDarkMode(); - const data = React.useMemo( - () => buildChartDataFromActivity({ activity, date, totalDailyActivity }), - [activity, date, totalDailyActivity], - ); - - return ( - - ); -}; diff --git a/src/popup/pages/activity/components/DailyActivityTab.tsx b/src/popup/pages/activity/components/DailyActivityTab.tsx new file mode 100644 index 0000000..60a592c --- /dev/null +++ b/src/popup/pages/activity/components/DailyActivityTab.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; + +import { Icon, IconType } from '@shared/blocks/Icon'; +import { Panel, PanelHeader } from '@shared/blocks/Panel'; +import { ActivityDoughnutChart } from '@shared/components/ActivityDoughnutChart'; +import { TimelineRecord } from '@shared/db/types'; +import { i18n } from '@shared/services/i18n'; +import { getActivityTimeline } from '@shared/tables/activity-timeline'; +import { IsoDate } from '@shared/types'; +import { getMinutesInMs } from '@shared/utils/date'; +import { getTotalDailyActivity } from '@shared/utils/time-store'; + +import { ActivityTimelineChart } from '@popup/components/ActivityTimelineChart'; +import { TimeUsagePanel } from '@popup/components/TimeUsagePanel'; +import { useIsDarkMode } from '@popup/hooks/useTheme'; +import { TimeStore } from '@popup/hooks/useTimeStore'; +import { useTotalWebsiteActivity } from '@popup/hooks/useTotalWebsiteActivity'; +import { WebsiteActivityTable } from '@popup/pages/activity/components/WebsiteActivityTable'; + +const MINUTE_IN_MS = getMinutesInMs(1); + +export interface DailyActivityTabProps { + store: TimeStore; + date: IsoDate; +} + +const useActivityTimeline = (date: IsoDate, filteredHostname?: string) => { + const [activityTimeline, setActivityTimeline] = React.useState< + TimelineRecord[] + >([]); + + React.useEffect(() => { + getActivityTimeline(date).then((timeline) => { + setActivityTimeline( + filteredHostname + ? timeline.filter((record) => record.hostname === filteredHostname) + : timeline, + ); + }); + }, [date, filteredHostname]); + + return activityTimeline; +}; + +export const DailyActivityTab: React.FC = ({ + store, + date, +}) => { + const [filteredHostname, setFilteredHostname] = React.useState< + string | undefined + >(undefined); + const activityByDate = React.useMemo(() => store[date] ?? {}, [store, date]); + const totalDailyActivity = React.useMemo( + () => getTotalDailyActivity(store, new Date(date)), + [store, date], + ); + const { weeklyUsage } = useTotalWebsiteActivity(store); + const activityTimeline = useActivityTimeline(date, filteredHostname); + + const scrollToRef = React.useRef(null); + + const handleDomainRowClick = React.useCallback((domain: string) => { + setFilteredHostname(domain); + scrollToRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, []); + + const isDarkMode = useIsDarkMode(); + + const activityTimelineHeader = filteredHostname + ? i18n('ActivityPageDailyActivityTab_ActivityTimelineHeaderOnHostname', { + hostname: filteredHostname, + }) + : i18n('ActivityPageDailyActivityTab_ActivityTimelineHeader'); + + return ( + <> + + + + + {i18n('ActivityPageDailyActivityTab_TopFiveActiveWebsites', { date })} + + {totalDailyActivity > MINUTE_IN_MS ? ( +
+ +
+ ) : ( +
+ {i18n('ActivityPageDailyActivityTab_EmptyActivity')} +
+ )} +
+
+ +
+ + + ); +}; diff --git a/src/popup/pages/activity/components/WebsiteActivityTable.tsx b/src/popup/pages/activity/components/WebsiteActivityTable.tsx new file mode 100644 index 0000000..c470b87 --- /dev/null +++ b/src/popup/pages/activity/components/WebsiteActivityTable.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; + +import { ActivitySummaryByHostname } from '@shared/db/types'; +import { selectHostnames } from '@shared/tables/domain-info'; + +import { ActivityTable } from '@popup/components/ActivityTable'; +import { usePopupContext } from '@popup/hooks/PopupContext'; + +export interface ActivityTableProps { + websiteTimeMap: ActivitySummaryByHostname; + title: string; + onDomainRowClicked?: (domain: string) => void; + onFilterDomainButtonClicked?: (domain: string) => void; + onUndoFilterDomainButtonClicked?: (domain: string) => void; +} + +export const WebsiteActivityTable: React.FC = React.memo( + ({ websiteTimeMap: activity, title, onDomainRowClicked }) => { + const { settings, updateSettings } = usePopupContext(); + + const [domainFavIconMap, setDomainFavIconMap] = React.useState< + Map + >(() => new Map()); + + const handleHideDomainClick = React.useCallback( + (domain: string) => { + updateSettings({ + ignoredHosts: Array.from(new Set([...settings.ignoredHosts, domain])), + }); + }, + [settings.ignoredHosts, updateSettings], + ); + + React.useEffect(() => { + selectHostnames(Object.keys(activity).map((domain) => domain)).then( + (domainInfo) => { + setDomainFavIconMap( + new Map( + domainInfo.map(({ hostname, iconUrl }) => [hostname, iconUrl]), + ), + ); + }, + ); + }, [activity]); + + return ( + + ); + }, +); diff --git a/src/popup/pages/activity/components/WeeklyActivityTab.tsx b/src/popup/pages/activity/components/WeeklyActivityTab.tsx new file mode 100644 index 0000000..5721c1e --- /dev/null +++ b/src/popup/pages/activity/components/WeeklyActivityTab.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; + +import { i18n } from '@shared/services/i18n'; +import { HostName } from '@shared/types'; +import { getTotalWeeklyActivity } from '@shared/utils/time-store'; + +import { TimeUsagePanel } from '@popup/components/TimeUsagePanel'; +import { TimeStore } from '@popup/hooks/useTimeStore'; +import { + getTimeStoreWeekSlice, + getTotalWebsiteActivity, + getTimeStoreWebsiteSlice, +} from '@popup/services/time-store'; + +import { WebsiteActivityTable } from './WebsiteActivityTable'; +import { WeeklyWebsiteActivityChart } from './WeeklyWebsiteActivityChart'; + +export interface ActivityPageWeeklyActivityTabProps { + store: TimeStore; + sundayDate: Date; +} + +const ACTIVITY_ON_ALL_WEBSITES = 'ALL_WEBSITES'; + +export const WeeklyActivityTab: React.FC< + ActivityPageWeeklyActivityTabProps +> = ({ store, sundayDate }) => { + const [pickedDomain, setPickedDomain] = React.useState< + typeof ACTIVITY_ON_ALL_WEBSITES | HostName + >(ACTIVITY_ON_ALL_WEBSITES); + const scrollToRef = React.useRef(null); + + const handleDomainRowClick = React.useCallback((domain: string) => { + // TODO: check HostName + setPickedDomain(domain as HostName); + scrollToRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, []); + + const weekActivitySlice = React.useMemo( + () => getTimeStoreWeekSlice(store, sundayDate), + [store, sundayDate], + ); + + const weeklyActivityByDomain = React.useMemo(() => { + if (pickedDomain === ACTIVITY_ON_ALL_WEBSITES) { + return weekActivitySlice; + } + + return getTimeStoreWebsiteSlice(weekActivitySlice, [pickedDomain]); + }, [weekActivitySlice, pickedDomain]); + + const weeklyActivityTotal = React.useMemo( + () => getTotalWebsiteActivity(weekActivitySlice), + [weekActivitySlice], + ); + + const weeklyActivityAverage = React.useMemo(() => { + return getTotalWeeklyActivity(weeklyActivityByDomain, sundayDate) / 7; + }, [weeklyActivityByDomain, sundayDate]); + + const presentChartTitle = React.useCallback( + () => + i18n('ActivityPageWeeklyTab_WeeklyWebsiteActivityChartTitle', { + domain: + pickedDomain === ACTIVITY_ON_ALL_WEBSITES + ? i18n('ActivityPageWeeklyTab_AllWebsites') + : pickedDomain, + }), + [pickedDomain], + ); + + return ( +
+ +
+ +
+ +
+ ); +}; diff --git a/src/popup/pages/activity/WeeklyWebsiteActivityChart.tsx b/src/popup/pages/activity/components/WeeklyWebsiteActivityChart.tsx similarity index 76% rename from src/popup/pages/activity/WeeklyWebsiteActivityChart.tsx rename to src/popup/pages/activity/components/WeeklyWebsiteActivityChart.tsx index 2881abb..6943efa 100644 --- a/src/popup/pages/activity/WeeklyWebsiteActivityChart.tsx +++ b/src/popup/pages/activity/components/WeeklyWebsiteActivityChart.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; -import { Bar } from 'react-chartjs-2'; import { Icon, IconType } from '@shared/blocks/Icon'; import { Panel, PanelHeader } from '@shared/blocks/Panel'; +import { Bar, ChartOptions } from '@shared/libs/ChartJs'; +import { i18n } from '@shared/services/i18n'; import { - get7DaysPriorDate, + generatePrior7DaysDates, getHoursInMs, getIsoDate, getTimeFromMs, @@ -12,8 +13,8 @@ import { } from '@shared/utils/date'; import { getTotalDailyActivity } from '@shared/utils/time-store'; -import { useIsDarkMode } from '../../hooks/useTheme'; -import { TimeStore } from '../../hooks/useTimeStore'; +import { useIsDarkMode } from '@popup/hooks/useTheme'; +import { TimeStore } from '@popup/hooks/useTimeStore'; export interface WeeklyWebsiteActivityChartProps { store: TimeStore; @@ -29,8 +30,8 @@ const BAR_OPTIONS = { y: { ticks: { color: '#222', - callback: (value: number) => { - return getTimeWithoutSeconds(value * HOUR_IN_MS); + callback: (value) => { + return getTimeWithoutSeconds(Number(value) * HOUR_IN_MS); }, }, }, @@ -46,10 +47,10 @@ const BAR_OPTIONS = { }, tooltip: { callbacks: { - title: ([item]: any) => { + title: ([item]) => { return `${item?.label}`; }, - label: (item: any) => { + label: (item) => { return ( ' ' + getTimeFromMs(Number(item.formattedValue || 0) * HOUR_IN_MS) ); @@ -57,7 +58,7 @@ const BAR_OPTIONS = { }, }, }, -}; +} satisfies ChartOptions<'bar'>; const DARK_MODE_BAR_OPTIONS = { ...BAR_OPTIONS, @@ -84,7 +85,7 @@ const DARK_MODE_BAR_OPTIONS = { }, }, }, -}; +} satisfies ChartOptions<'bar'>; export const WeeklyWebsiteActivityChart: React.FC< WeeklyWebsiteActivityChartProps @@ -92,13 +93,12 @@ export const WeeklyWebsiteActivityChart: React.FC< const isDarkMode = useIsDarkMode(); const [labels, data] = React.useMemo(() => { - const week = get7DaysPriorDate(sundayDate).reverse(); - const labels = week.map((date) => getIsoDate(date)); - const data = week.map( - (date) => getTotalDailyActivity(store, date) / HOUR_IN_MS, - ); + const week = generatePrior7DaysDates(sundayDate).reverse(); - return [labels, data]; + return [ + week.map((date) => getIsoDate(date)), + week.map((date) => getTotalDailyActivity(store, date) / HOUR_IN_MS), + ]; }, [store, sundayDate]); const weekName = React.useMemo( @@ -111,7 +111,7 @@ export const WeeklyWebsiteActivityChart: React.FC< labels: labels, datasets: [ { - label: 'Weekly activity', + label: i18n('WeeklyWebsiteActivityChart_ChartLabel'), data: data, backgroundColor: '#4b76e3', borderRadius: 12, diff --git a/src/popup/pages/overall/OverallActivityCalendar/helpers.ts b/src/popup/pages/overall/OverallActivityCalendar/helpers.ts deleted file mode 100644 index edd5d20..0000000 --- a/src/popup/pages/overall/OverallActivityCalendar/helpers.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { getHoursInMs, getMinutesInMs } from '@shared/utils/date'; - -import { - CalendarDisplayedActivity, - CalendarDisplayedActivityType, - TotalDailyActivity, -} from '../GithubCalendarWrapper'; - -export const getActivityLevel = ( - timeInMs: number, -): CalendarDisplayedActivityType => { - if (timeInMs < getMinutesInMs(1)) { - return CalendarDisplayedActivityType.Inactive; - } - - if (timeInMs < getHoursInMs(2.5)) { - return CalendarDisplayedActivityType.Low; - } - - if (timeInMs < getHoursInMs(7)) { - return CalendarDisplayedActivityType.Medium; - } - - return CalendarDisplayedActivityType.High; -}; - -export const convertCombinedDailyActivityToCalendarActivity = ( - totalActivity: Record = {}, -): CalendarDisplayedActivity => - Object.fromEntries( - Object.entries(totalActivity).map(([key, value]) => [ - key, - getActivityLevel(value), - ]), - ); - -export const getCombinedTotalDailyActivity = ( - totalActivity: TotalDailyActivity = {}, -) => { - return Object.fromEntries( - Object.entries(totalActivity).map( - ([key, value]) => - [ - key, - Object.values(value).reduce((acc, val) => acc + val, 0), - ] satisfies [key: string, activitySum: number], - ), - ); -}; diff --git a/src/popup/pages/overall/OverallActivityCalendar/index.tsx b/src/popup/pages/overall/OverallActivityCalendar/index.tsx deleted file mode 100644 index 2efb06d..0000000 --- a/src/popup/pages/overall/OverallActivityCalendar/index.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from 'react'; - -import { Icon, IconType } from '@shared/blocks/Icon'; -import { Panel, PanelBody, PanelHeader } from '@shared/blocks/Panel'; -import { getTimeWithoutSeconds } from '@shared/utils/date'; - -import { - GithubCalendarWrapper, - TotalDailyActivity, -} from '../GithubCalendarWrapper'; -import { - convertCombinedDailyActivityToCalendarActivity, - getCombinedTotalDailyActivity, -} from './helpers'; - -export interface OverallActivityCalendarProps { - store: TotalDailyActivity; - navigateToDateActivityPage: (isoDate: string) => void; -} - -export const OverallActivityCalendarPanel: React.FC< - OverallActivityCalendarProps -> = ({ store, navigateToDateActivityPage }) => { - const totalDailyActivity = getCombinedTotalDailyActivity(store); - const calendarActivity = - convertCombinedDailyActivityToCalendarActivity(totalDailyActivity); - - const getTooltipForDateButton = React.useCallback( - (isoDate) => { - const time = totalDailyActivity[isoDate]; - if (time !== undefined) { - return `${isoDate} ${getTimeWithoutSeconds(time)}`; - } - - return isoDate; - }, - [totalDailyActivity], - ); - - return ( - - - - Overall Activity Map - - - - - - ); -}; diff --git a/src/popup/pages/overall/OverallPage.tsx b/src/popup/pages/overall/OverallPage.tsx new file mode 100644 index 0000000..3df2c3e --- /dev/null +++ b/src/popup/pages/overall/OverallPage.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; + +import { i18n } from '@shared/services/i18n'; +import { getMinutesInMs } from '@shared/utils/date'; + +import { ActivityTimelineChart } from '@popup/components/ActivityTimelineChart'; +import { TimeUsagePanel } from '@popup/components/TimeUsagePanel'; +import { usePopupContext } from '@popup/hooks/PopupContext'; +import { useActiveTabTime } from '@popup/hooks/useActiveTabTime'; +import { useLastSixHoursTimelineEvents } from '@popup/hooks/useLastSixHoursTimeline'; +import { useIsDarkMode } from '@popup/hooks/useTheme'; +import { useTotalWebsiteActivity } from '@popup/hooks/useTotalWebsiteActivity'; + +import { OverallActivityCalendar } from './components/OverallActivityCalendar'; + +export interface OverallPageProps { + onNavigateToActivityPage: React.ComponentProps< + typeof OverallActivityCalendar + >['onDateClick']; +} + +const MINUTE_IN_MS = getMinutesInMs(1); + +export const OverallPage: React.FC = ({ + onNavigateToActivityPage, +}) => { + const { store, activeHostname } = usePopupContext(); + const { todaysUsage, weeklyUsage } = useTotalWebsiteActivity(store); + const timelineEvents = useLastSixHoursTimelineEvents(); + const { time: activeWebsiteTime, weekTime: activeWebsiteWeekTime } = + useActiveTabTime(); + const isDarkMode = useIsDarkMode(); + + return ( +
+ {todaysUsage > MINUTE_IN_MS ? ( + + ) : null} + {activeWebsiteTime ? ( + + ) : null} + + +
+ ); +}; diff --git a/src/popup/pages/overall/_stories/OverallActivityCalendar.stories.tsx b/src/popup/pages/overall/_stories/OverallActivityCalendar.stories.tsx new file mode 100644 index 0000000..d79dad0 --- /dev/null +++ b/src/popup/pages/overall/_stories/OverallActivityCalendar.stories.tsx @@ -0,0 +1,41 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { TimeStore } from '@shared/db/types'; +import { PredefinedIsoDates } from '@shared/utils/date'; + +import { OverallActivityCalendar } from '../components/OverallActivityCalendar'; + +export default { + title: 'popup/OverallPage components/OverallActivityCalendar', + component: OverallActivityCalendar, + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +type Story = StoryObj>; + +const STORE = { + [PredefinedIsoDates.today]: { + 'example.com': 10000000, + 'example.org': 20000, + }, + [PredefinedIsoDates.yesterday]: { + 'example.com': 30000, + 'example.org': 40000, + }, + [PredefinedIsoDates.lastMonth]: { + 'example.com': 50000, + 'example.org': 600000, + }, +} as unknown as TimeStore; + +export const Default: Story = { + render: () => ( + alert(date)} + store={STORE} + /> + ), +}; diff --git a/src/popup/pages/overall/components/OverallActivityCalendar.tsx b/src/popup/pages/overall/components/OverallActivityCalendar.tsx new file mode 100644 index 0000000..1025b47 --- /dev/null +++ b/src/popup/pages/overall/components/OverallActivityCalendar.tsx @@ -0,0 +1,82 @@ +import React, { useCallback, useMemo } from 'react'; + +import { Icon, IconType } from '@shared/blocks/Icon'; +import { Panel, PanelBody, PanelHeader } from '@shared/blocks/Panel'; +import { + ActivityCalendar, + CalendarDisplayedActivity, + CalendarDisplayedActivityType, +} from '@shared/components/ActivityCalendar'; +import { TimeStore } from '@shared/db/types'; +import { i18n } from '@shared/services/i18n'; +import { IsoDate } from '@shared/types'; +import { getHoursInMs, getMinutesInMs } from '@shared/utils/date'; + +import { + getTotalDailyActivity, + ActivitySummaryByDate, + transformDailyActivityValues, +} from '@popup/services/time-store'; + +export const getCalendarActivity = ( + totalActivity: ActivitySummaryByDate, +): CalendarDisplayedActivity => + transformDailyActivityValues(totalActivity, (_date, timeMs) => { + if (timeMs < getMinutesInMs(1)) { + return CalendarDisplayedActivityType.Inactive; + } + + if (timeMs < getHoursInMs(2.5)) { + return CalendarDisplayedActivityType.Low; + } + + if (timeMs < getHoursInMs(7)) { + return CalendarDisplayedActivityType.Medium; + } + + return CalendarDisplayedActivityType.High; + }); + +export interface OverallActivityCalendarProps { + store: TimeStore; + onDateClick: (isoDate: IsoDate) => void; +} + +export const OverallActivityCalendar: React.FC< + OverallActivityCalendarProps +> = ({ store, onDateClick }) => { + const totalDailyActivity = useMemo( + () => getTotalDailyActivity(store), + [store], + ); + + const calendarActivity = useMemo( + () => getCalendarActivity(totalDailyActivity), + [totalDailyActivity], + ); + + const getTooltipForDateButton = useCallback( + (isoDate: IsoDate) => { + return [isoDate, totalDailyActivity[isoDate]] + .filter((value) => value !== undefined) + .join(' '); + }, + [totalDailyActivity], + ); + + return ( + + + + {i18n('OverallActivityCalendar_Header')} + + + + + + ); +}; diff --git a/src/popup/pages/PreferencesPage.tsx b/src/popup/pages/preferences/PreferencesPage.tsx similarity index 52% rename from src/popup/pages/PreferencesPage.tsx rename to src/popup/pages/preferences/PreferencesPage.tsx index 8e7bd25..d3985bb 100644 --- a/src/popup/pages/PreferencesPage.tsx +++ b/src/popup/pages/preferences/PreferencesPage.tsx @@ -2,12 +2,13 @@ import * as React from 'react'; import { FC } from 'react'; import { Panel, PanelBody, PanelHeader } from '@shared/blocks/Panel'; +import { i18n } from '@shared/services/i18n'; -import { BackupSetting } from './preferences/Backup'; -import { DisplayTimeOnBadge } from './preferences/DisplayTimeOnBadgeSetting'; -import { IgnoredDomainSetting } from './preferences/IgnoredDomainSetting'; -import { LimitsSetting } from './preferences/LimitsSetting'; -import { ThemeSelector } from './preferences/ThemeSelector'; +import { BackupSetting } from './components/Backup'; +import { DisplayTimeOnBadge } from './components/DisplayTimeOnBadgeSetting'; +import { IgnoredDomainSetting } from './components/IgnoredDomainSetting'; +import { LimitsSetting } from './components/LimitsSetting'; +import { ThemeSelector } from './components/ThemeSelector'; export const PreferencesPage: FC = () => { return ( @@ -15,8 +16,7 @@ export const PreferencesPage: FC = () => { - {/* App preferences */} - Preferences + {i18n('PreferencesPage_Header')} diff --git a/src/popup/pages/preferences/Backup.tsx b/src/popup/pages/preferences/components/Backup.tsx similarity index 81% rename from src/popup/pages/preferences/Backup.tsx rename to src/popup/pages/preferences/components/Backup.tsx index d5abdc3..8d420c0 100644 --- a/src/popup/pages/preferences/Backup.tsx +++ b/src/popup/pages/preferences/components/Backup.tsx @@ -4,9 +4,10 @@ import { Button, ButtonType } from '@shared/blocks/Button'; import { Icon, IconType } from '@shared/blocks/Icon'; import { Panel, PanelBody, PanelHeader } from '@shared/blocks/Panel'; import { TimelineRecord } from '@shared/db/types'; -import { getAllActivityTimeline } from '@shared/tables/activity-timeline'; +import { i18n } from '@shared/services/i18n'; +import { getFullActivityTimeline } from '@shared/tables/activity-timeline'; -import { usePopupContext } from '../../hooks/PopupContext'; +import { usePopupContext } from '../../../hooks/PopupContext'; function getActivityTimelineTimeInSeconds(t: TimelineRecord) { return Math.round( @@ -18,7 +19,7 @@ export const BackupSetting: React.FC = () => { const { settings } = usePopupContext(); const handleExportCSV = React.useCallback(async () => { - const timeline = await getAllActivityTimeline(); + const timeline = await getFullActivityTimeline(); const csv = [ 'Date,Domain,Page Title,Time start, Time end,Time Spent (seconds)', ...timeline.map((t) => presentTimeline(t)), @@ -49,7 +50,7 @@ export const BackupSetting: React.FC = () => { }, []); const handleExportJSON = React.useCallback(async () => { - const timeline = await getAllActivityTimeline(); + const timeline = await getFullActivityTimeline(); const blob = new Blob( [ JSON.stringify({ @@ -72,13 +73,14 @@ export const BackupSetting: React.FC = () => { return ( - Backup + {i18n('Backup_Header')} diff --git a/src/popup/pages/preferences/DisplayTimeOnBadgeSetting.tsx b/src/popup/pages/preferences/components/DisplayTimeOnBadgeSetting.tsx similarity index 83% rename from src/popup/pages/preferences/DisplayTimeOnBadgeSetting.tsx rename to src/popup/pages/preferences/components/DisplayTimeOnBadgeSetting.tsx index 63458da..945e423 100644 --- a/src/popup/pages/preferences/DisplayTimeOnBadgeSetting.tsx +++ b/src/popup/pages/preferences/components/DisplayTimeOnBadgeSetting.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; import { Checkbox } from '@shared/blocks/Input'; +import { i18n } from '@shared/services/i18n'; -import { usePopupContext } from '../../hooks/PopupContext'; +import { usePopupContext } from '../../../hooks/PopupContext'; export const DisplayTimeOnBadge: React.FC = () => { const { settings, updateSettings } = usePopupContext(); @@ -26,7 +27,7 @@ export const DisplayTimeOnBadge: React.FC = () => { checked={isDisplayTimeOnIconChecked} onChange={handleDisplayTimeOnIconChange} /> - Display active time on icon + {i18n('DisplayTimeOnBadge_OptionToEnable')} ); }; diff --git a/src/popup/pages/preferences/IgnoredDomainSetting.tsx b/src/popup/pages/preferences/components/IgnoredDomainSetting.tsx similarity index 86% rename from src/popup/pages/preferences/IgnoredDomainSetting.tsx rename to src/popup/pages/preferences/components/IgnoredDomainSetting.tsx index 7f24c16..176058a 100644 --- a/src/popup/pages/preferences/IgnoredDomainSetting.tsx +++ b/src/popup/pages/preferences/components/IgnoredDomainSetting.tsx @@ -5,15 +5,16 @@ import { Button, ButtonType } from '@shared/blocks/Button'; import { Icon, IconType } from '@shared/blocks/Icon'; import { Input } from '@shared/blocks/Input'; import { Panel, PanelBody, PanelHeader } from '@shared/blocks/Panel'; +import { i18n } from '@shared/services/i18n'; import { assertIsValidHostname } from '@shared/utils/url'; -import { usePopupContext } from '../../hooks/PopupContext'; +import { usePopupContext } from '@popup/hooks/PopupContext'; export const IgnoredDomainSetting: React.FC = () => { const { settings, updateSettings } = usePopupContext(); - const [ignoredDomains, setIgnoredDomains] = React.useState( - settings.ignoredHosts, - ); + const [ignoredDomains, setIgnoredDomains] = React.useState< + Readonly + >(settings.ignoredHosts); const [domainToIgnore, setDomainToIgnore] = React.useState(''); const [isDomainsListExpanded, setDomainsListExpanded] = React.useState(false); @@ -67,12 +68,12 @@ export const IgnoredDomainSetting: React.FC = () => { return ( - Ignored domains + {i18n('IgnoredDomainSetting_Header')} -

You can hide unwanted websites to keep dashboards clean.

+

{i18n('IgnoredDomainSetting_FeatureDescription')}

@@ -93,7 +94,7 @@ export const IgnoredDomainSetting: React.FC = () => { className="text-blue-500" onClick={handleToggleDomainsListExpanded} > - View all ignored domains + {i18n('IgnoredDomainSetting_ViewAllIgnoredDomainsLink')}
{!ignoredDomains.length && ( diff --git a/src/popup/pages/preferences/LimitsSetting.tsx b/src/popup/pages/preferences/components/LimitsSetting.tsx similarity index 90% rename from src/popup/pages/preferences/LimitsSetting.tsx rename to src/popup/pages/preferences/components/LimitsSetting.tsx index 446722f..e05e70c 100644 --- a/src/popup/pages/preferences/LimitsSetting.tsx +++ b/src/popup/pages/preferences/components/LimitsSetting.tsx @@ -4,9 +4,10 @@ import { Button, ButtonType } from '@shared/blocks/Button'; import { Icon, IconType } from '@shared/blocks/Icon'; import { Input, Time } from '@shared/blocks/Input'; import { Panel, PanelBody, PanelHeader } from '@shared/blocks/Panel'; +import { i18n } from '@shared/services/i18n'; import { assertIsValidHostname } from '@shared/utils/url'; -import { usePopupContext } from '../../hooks/PopupContext'; +import { usePopupContext } from '@popup/hooks/PopupContext'; export const LimitsSetting: React.FC = () => { const { settings, updateSettings } = usePopupContext(); @@ -79,15 +80,12 @@ export const LimitsSetting: React.FC = () => { return ( - Limits + {i18n('LimitsSetting_Header')} -

- Add time limits to your daily web activity on specific websites. Once - limit reached website will become black and white. -

+

{i18n('LimitsSetting_FeatureDescription')}

{limitsEntries.length > 0 ? ( diff --git a/src/popup/pages/preferences/ThemeSelector.tsx b/src/popup/pages/preferences/components/ThemeSelector.tsx similarity index 85% rename from src/popup/pages/preferences/ThemeSelector.tsx rename to src/popup/pages/preferences/components/ThemeSelector.tsx index 92b3df8..63d90a4 100644 --- a/src/popup/pages/preferences/ThemeSelector.tsx +++ b/src/popup/pages/preferences/components/ThemeSelector.tsx @@ -3,6 +3,7 @@ import { twMerge } from 'tailwind-merge'; import { Button } from '@shared/blocks/Button'; import { Icon, IconType } from '@shared/blocks/Icon'; +import { i18n } from '@shared/services/i18n'; import { ColorScheme, ThemeService } from '@shared/services/theme'; export const ThemeSelector: React.FC = () => { @@ -30,7 +31,7 @@ export const ThemeSelector: React.FC = () => { return (
-

Theme

+

{i18n('ThemeSelector_Header')}

diff --git a/src/popup/services/time-store.ts b/src/popup/services/time-store.ts new file mode 100644 index 0000000..64fa050 --- /dev/null +++ b/src/popup/services/time-store.ts @@ -0,0 +1,125 @@ +import { ActivitySummaryByHostname, TimeStore } from '@shared/db/types'; +import { HostName, IsoDate } from '@shared/types'; +import { generatePrior7DaysDates, getIsoDate } from '@shared/utils/date'; + +export { ActivitySummaryByHostname }; +export type ActivitySummaryByDate = Record; + +export const getTotalDailyActivity = ( + store: TimeStore, +): ActivitySummaryByDate => { + const result: ActivitySummaryByDate = {}; + + for (const [date, activity] of Object.entries(store)) { + result[date as IsoDate] = Object.values(activity).reduce( + (acc, val) => acc + val, + 0, + ); + } + + return result; +}; + +export const calculateTotalActivity = ( + activity: ActivitySummaryByDate | ActivitySummaryByHostname, +): number => { + return Object.values(activity).reduce((sum, val) => sum + val, 0); +}; + +export const transformDailyActivityValues = ( + activityByDate: ActivitySummaryByDate, + mapFn: (date: IsoDate, time: number) => number, +): ActivitySummaryByDate => { + const result: ActivitySummaryByDate = {}; + + for (const [date, time] of Object.entries(activityByDate)) { + result[date as IsoDate] = mapFn(date as IsoDate, time); + } + + return result; +}; + +export const getTotalWebsiteActivity = ( + store: TimeStore, +): ActivitySummaryByHostname => { + const result: ActivitySummaryByHostname = {}; + + for (const activity of Object.values(store)) { + for (const [domain, time] of Object.entries(activity)) { + const hostname = domain as HostName; + + result[hostname] ??= 0; + result[hostname] += time; + } + } + + return result; +}; + +export const getFilteredWebsiteTimeStoreSlice = ( + store: TimeStore, + filterFn: (date: IsoDate, hostname: HostName, time: number) => boolean, +) => { + const result: TimeStore = {}; + + for (const [date, activity] of Object.entries(store)) { + const isoDate = date as IsoDate; + result[isoDate] = Object.fromEntries( + Object.entries(activity).filter(([domain, time]) => + filterFn(isoDate, domain as HostName, time), + ), + ); + } + + return result; +}; + +export const getTimeStoreWebsiteSlice = ( + store: TimeStore, + hostnames: [HostName, ...HostName[]], +): TimeStore => { + const result: TimeStore = {}; + + for (const [date, activity] of Object.entries(store)) { + result[date as IsoDate] = Object.fromEntries( + Object.entries(activity).filter(([domain]) => + hostnames.includes(domain as HostName), + ), + ); + } + + return result; +}; + +export const getTimeStoreWeekSlice = ( + store: TimeStore, + endDate: Date, +): TimeStore => { + const result: TimeStore = {}; + + for (const date of generatePrior7DaysDates(endDate)) { + const isoDate = getIsoDate(date); + result[isoDate] = store[isoDate] ?? {}; + } + + return result; +}; + +export const transformTimeStoreValues = ( + store: TimeStore, + mapFn: (date: IsoDate, key: HostName, time: number) => number, +): TimeStore => { + const result: TimeStore = {}; + + for (const [date, activity] of Object.entries(store)) { + const isoDate = date as IsoDate; + for (const [domain, time] of Object.entries(activity)) { + const hostname = domain as HostName; + + result[isoDate] ??= {}; + result[isoDate][hostname] = mapFn(isoDate, hostname, time); + } + } + + return result; +}; diff --git a/src/shared/components/ActivityCalendar/ActivityCalendar.css b/src/shared/components/ActivityCalendar/ActivityCalendar.css new file mode 100644 index 0000000..16b3b18 --- /dev/null +++ b/src/shared/components/ActivityCalendar/ActivityCalendar.css @@ -0,0 +1,4 @@ +.calendar svg rect { + cursor: pointer; + rx: 3; +} diff --git a/src/popup/pages/overall/GithubCalendarWrapper.tsx b/src/shared/components/ActivityCalendar/index.tsx similarity index 65% rename from src/popup/pages/overall/GithubCalendarWrapper.tsx rename to src/shared/components/ActivityCalendar/index.tsx index ba56e59..48e304a 100644 --- a/src/popup/pages/overall/GithubCalendarWrapper.tsx +++ b/src/shared/components/ActivityCalendar/index.tsx @@ -1,30 +1,31 @@ import * as React from 'react'; import Calendar from 'react-github-contribution-calendar'; -import ReactTooltip from 'react-tooltip'; +import { Tooltip, TooltipRefProps } from 'react-tooltip'; import { debounce } from 'throttle-debounce'; -import { getIsoDate } from '@shared/utils/date'; +import { IsoDate } from '@shared/types'; +import { assertIsIsoDate, getIsoDate } from '@shared/utils/date'; + +import './ActivityCalendar.css'; export enum CalendarDisplayedActivityType { Inactive = 0, - Low, - Medium, - High, + Low = 1, + Medium = 2, + High = 3, } export type CalendarDisplayedActivity = Record< - string, + IsoDate, CalendarDisplayedActivityType >; -export type GithubCalendarProps = { +export type ActivityCalendarProps = { activity: CalendarDisplayedActivity; - onDateClick: (isoDate: string) => void; - getTooltip?: (isoDate: string) => string; + onDateClick: (isoDate: IsoDate) => void; + getTooltip?: (isoDate: IsoDate) => string; }; -export type TotalDailyActivity = Record>; - const INACTIVE_DAY_COLOR = '#cccccc'; const LOW_ACTIVITY_DAY_COLOR = '#839dde'; const MEDIUM_ACTIVITY_DAY_COLOR = '#4b76e3'; @@ -40,13 +41,15 @@ const BUTTON_DATE_ATTRIBUTE = 'data-date'; const REACT_TOOLTIP_ID = 'activity-calendar'; const REACT_TOOLTIP_SHOW_DELAY_MS = 100; +class GhCalendar extends Calendar {} + const getDefaultTooltip = (date: string) => date; const debouncedSetCalendarTooltips = debounce( 200, ( calendarContainer: HTMLDivElement, - getTooltip: Required['getTooltip'], + getTooltip: Required['getTooltip'], ) => { const elements = calendarContainer.querySelectorAll('rect'); @@ -59,15 +62,13 @@ const debouncedSetCalendarTooltips = debounce( const elementIsoDate = getIsoDate(elementDate); el.setAttribute(BUTTON_DATE_ATTRIBUTE, elementIsoDate); - el.setAttribute('data-tip', getTooltip(elementIsoDate)); - el.setAttribute('data-for', REACT_TOOLTIP_ID); + el.setAttribute('data-tooltip-content', getTooltip(elementIsoDate)); + el.setAttribute('data-tooltip-id', REACT_TOOLTIP_ID); }); - - ReactTooltip.rebuild(); }, ); -export const GithubCalendarWrapper: React.FC = ({ +export const ActivityCalendar: React.FC = ({ activity, onDateClick, getTooltip = getDefaultTooltip, @@ -89,18 +90,29 @@ export const GithubCalendarWrapper: React.FC = ({ return; } - onDateClick( - target.getAttribute(BUTTON_DATE_ATTRIBUTE) || getIsoDate(new Date()), - ); + const date = + target.getAttribute(BUTTON_DATE_ATTRIBUTE) || getIsoDate(new Date()); + + assertIsIsoDate(date); + onDateClick(date); }, [onDateClick], ); + const ref = React.useRef(null); return (
- {/* @ts-expect-error -- expected, this element does have props */} - - + + diff --git a/src/shared/components/ActivityDoughnutChart/ActivityDoughnutChart.tsx b/src/shared/components/ActivityDoughnutChart/ActivityDoughnutChart.tsx new file mode 100644 index 0000000..3621f2c --- /dev/null +++ b/src/shared/components/ActivityDoughnutChart/ActivityDoughnutChart.tsx @@ -0,0 +1,53 @@ +import React, { useMemo } from 'react'; + +import { ActivitySummaryByHostname } from '@shared/db/types'; +import { Doughnut } from '@shared/libs/ChartJs'; + +import { ActivitySummaryByDate } from '@popup/services/time-store'; + +import { + ACTIVITY_DOUGHNUT_CHART_OPTIONS, + DARK_MODE_ACTIVITY_DOUGHNUT_CHART_OPTIONS, + ITEMS_COLORS, +} from './config'; +import { createActivityChartDataset } from './helper'; + +export interface DailyUsageChartProps { + datasetLabel: string; + activity: ActivitySummaryByHostname | ActivitySummaryByDate; + isDarkMode: boolean; +} + +export const ActivityDoughnutChart: React.FC = ({ + activity, + datasetLabel, + isDarkMode, +}) => { + const chartData = useMemo(() => { + const { labels, data } = createActivityChartDataset(activity); + + return { + labels, + datasets: [ + { + label: datasetLabel, + data, + backgroundColor: ITEMS_COLORS, + }, + ], + }; + }, [activity, datasetLabel]); + + console.log(activity, chartData); + + return ( + + ); +}; diff --git a/src/shared/components/ActivityDoughnutChart/config.ts b/src/shared/components/ActivityDoughnutChart/config.ts new file mode 100644 index 0000000..835ce3f --- /dev/null +++ b/src/shared/components/ActivityDoughnutChart/config.ts @@ -0,0 +1,49 @@ +import { ChartOptions } from '@shared/libs/ChartJs'; +import { getMinutesInMs } from '@shared/utils/date'; + +export const ONE_MINUTE = getMinutesInMs(1); +export const MAX_ITEMS_TO_DISPLAY = 5; +export const ITEMS_COLORS = [ + '#ffa600', + '#f97144', + '#d44d63', + '#9a3f70', + '#5a3764', + '#262944', +]; + +export const ACTIVITY_DOUGHNUT_CHART_OPTIONS = { + responsive: true, + plugins: { + legend: { + position: 'left', + labels: { + color: '#222', + }, + }, + tooltip: { + callbacks: { + title: ([item]) => { + return `${item?.label}`; + }, + label: (item) => { + return ` ${item.formattedValue}%`; + }, + }, + }, + }, +} satisfies ChartOptions<'doughnut'>; + +export const DARK_MODE_ACTIVITY_DOUGHNUT_CHART_OPTIONS = { + ...ACTIVITY_DOUGHNUT_CHART_OPTIONS, + plugins: { + ...ACTIVITY_DOUGHNUT_CHART_OPTIONS.plugins, + legend: { + ...ACTIVITY_DOUGHNUT_CHART_OPTIONS.plugins.legend, + labels: { + ...ACTIVITY_DOUGHNUT_CHART_OPTIONS.plugins.legend.labels, + color: '#e5e5e5', + }, + }, + }, +} satisfies ChartOptions<'doughnut'>; diff --git a/src/shared/components/ActivityDoughnutChart/helper.ts b/src/shared/components/ActivityDoughnutChart/helper.ts new file mode 100644 index 0000000..dcbf116 --- /dev/null +++ b/src/shared/components/ActivityDoughnutChart/helper.ts @@ -0,0 +1,55 @@ +import { ActivitySummaryByHostname } from '@shared/db/types'; +import { getTimeWithoutSeconds } from '@shared/utils/date'; + +import { ActivitySummaryByDate } from '@popup/services/time-store'; + +import { MAX_ITEMS_TO_DISPLAY, ONE_MINUTE } from './config'; + +const presentChartLabel = (key: string, value: number) => { + const timeString = value > ONE_MINUTE ? getTimeWithoutSeconds(value) : '<1m'; + const label = `${key} (${timeString})`; + + return label; +}; + +export const createActivityChartDataset = ( + activity: ActivitySummaryByDate | ActivitySummaryByHostname, +) => { + const activityEntriesSortedDesc = Object.entries(activity).sort( + ([, value1], [, value2]) => { + return value2 - value1; + }, + ); + + const itemsToDisplay: Array<[string, number]> = []; + + let totalActivityTime = 0; + let restActivityTime = 0; + + for (const entry of activityEntriesSortedDesc) { + const [, time] = entry; + totalActivityTime += time; + + if (itemsToDisplay.length < MAX_ITEMS_TO_DISPLAY) { + itemsToDisplay.push(entry); + continue; + } + + restActivityTime += time; + } + + itemsToDisplay.push(['Other pages', restActivityTime]); + + const labels = itemsToDisplay.map(([key, value]) => + presentChartLabel(key, value), + ); + + const data = itemsToDisplay.map(([_, value]) => + Math.floor((value / totalActivityTime) * 100), + ); + + return { + labels, + data, + }; +}; diff --git a/src/shared/components/ActivityDoughnutChart/index.ts b/src/shared/components/ActivityDoughnutChart/index.ts new file mode 100644 index 0000000..b113be7 --- /dev/null +++ b/src/shared/components/ActivityDoughnutChart/index.ts @@ -0,0 +1 @@ +export { ActivityDoughnutChart } from './ActivityDoughnutChart'; diff --git a/src/shared/components/TimelineChart/config.ts b/src/shared/components/TimelineChart/config.ts index f577554..d2afcd4 100644 --- a/src/shared/components/TimelineChart/config.ts +++ b/src/shared/components/TimelineChart/config.ts @@ -1,18 +1,20 @@ +import { ChartOptions } from 'chart.js'; + export const TIMELINE_CHART_LIGHT_THEME_OPTIONS = { plugins: { legend: { display: false, }, tooltip: { - display: false, + enabled: false, callbacks: { - title: (items: any[]) => { + title: (items) => { const totalActivityThisHour = items.reduce((acc, item) => { const { raw } = item; - const [startMin = 0, endMin = 0] = raw; + const [startMin = 0, endMin = 0] = raw as [number, number]; return acc + (endMin - startMin); }, 0); - return `${totalActivityThisHour}m surfed between ${items[0].label}`; + return `${totalActivityThisHour}m surfed between ${items[0]?.label}`; }, label: () => void 0, }, @@ -34,13 +36,13 @@ export const TIMELINE_CHART_LIGHT_THEME_OPTIONS = { min: 0, ticks: { color: '#222222', - callback: (value: number) => { + callback: (value) => { return `:${value.toString().padStart(2, '0')}`; }, }, }, }, -}; +} satisfies ChartOptions<'bar'>; export const TIMELINE_CHART_DARK_THEME_OPTIONS = { ...TIMELINE_CHART_LIGHT_THEME_OPTIONS, @@ -67,4 +69,4 @@ export const TIMELINE_CHART_DARK_THEME_OPTIONS = { }, }, }, -}; +} satisfies ChartOptions<'bar'>; diff --git a/src/shared/components/TimelineChart/index.tsx b/src/shared/components/TimelineChart/index.tsx index a3e7fbf..fcc29e9 100644 --- a/src/shared/components/TimelineChart/index.tsx +++ b/src/shared/components/TimelineChart/index.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import { Bar } from 'react-chartjs-2'; import { TimelineRecord } from '@shared/db/types'; +import { Bar } from '@shared/libs/ChartJs'; import { TIMELINE_CHART_DARK_THEME_OPTIONS, diff --git a/src/shared/components/_stories/ActivityCalendar.stories.tsx b/src/shared/components/_stories/ActivityCalendar.stories.tsx new file mode 100644 index 0000000..c274a74 --- /dev/null +++ b/src/shared/components/_stories/ActivityCalendar.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import * as React from 'react'; + +import { getIsoDate } from '@shared/utils/date'; + +import { + ActivityCalendar, + CalendarDisplayedActivityType, +} from '../ActivityCalendar'; + +const meta: Meta = { + title: 'Components/ActivityCalendar', + component: ActivityCalendar, + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const today = new Date(); +const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); +const date3 = new Date(weekAgo.getTime() - 24 * 60 * 60 * 1000); +const date4 = new Date(date3.getTime() - 2 * 24 * 60 * 60 * 1000); +const date5 = new Date(date4.getTime() - 3 * 24 * 60 * 60 * 1000); + +const sampleActivity = { + [getIsoDate(today)]: CalendarDisplayedActivityType.High, + [getIsoDate(weekAgo)]: CalendarDisplayedActivityType.Medium, + [getIsoDate(date3)]: CalendarDisplayedActivityType.Low, + [getIsoDate(date4)]: CalendarDisplayedActivityType.Inactive, + [getIsoDate(date5)]: CalendarDisplayedActivityType.High, +}; + +const defaultTooltip = (date: string) => `Activity on ${date}`; + +export const Default: Story = { + render: () => ( + alert(`Clicked on date: ${date}`)} + /> + ), +}; diff --git a/src/shared/components/_stories/ActivityDoughnutChart.stories.tsx b/src/shared/components/_stories/ActivityDoughnutChart.stories.tsx new file mode 100644 index 0000000..8e25a83 --- /dev/null +++ b/src/shared/components/_stories/ActivityDoughnutChart.stories.tsx @@ -0,0 +1,49 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { ActivitySummaryByHostname } from '@shared/db/types'; + +import { ActivityDoughnutChart } from '../ActivityDoughnutChart'; + +export default { + title: 'Components/ActivityDoughnutChart', + component: ActivityDoughnutChart, +} satisfies Meta; + +type Story = StoryObj>; + +const ACTIVITY_BY_HOSTNAME = { + 'example.com': 120000, + 'example.org': 45000, + 'example.net': 30000, + 'example.a': 30000, + 'example.v': 30000, + 'example.c': 30000, +} as unknown as ActivitySummaryByHostname; + +export const Default: Story = { + render: () => ( + + ), + parameters: { + layout: 'centered', + }, +}; + +export const DarkMode: Story = { + render: () => ( + + ), + parameters: { + layout: 'centered', + backgrounds: { default: 'dark' }, + }, +}; diff --git a/src/shared/components/_stories/TimelineChart.stories.tsx b/src/shared/components/_stories/TimelineChart.stories.tsx index 04304df..449aa6f 100644 --- a/src/shared/components/_stories/TimelineChart.stories.tsx +++ b/src/shared/components/_stories/TimelineChart.stories.tsx @@ -49,3 +49,7 @@ export const DarkTheme: Story = { ), }; + +export const EmptyTimeline: Story = { + render: () => , +}; diff --git a/src/shared/db/sync-storage.ts b/src/shared/db/sync-storage.ts index d5be76b..700e157 100644 --- a/src/shared/db/sync-storage.ts +++ b/src/shared/db/sync-storage.ts @@ -1,3 +1,4 @@ +import { HostName, IsoDate } from '@shared/types'; import { getIsoDate } from '@shared/utils/date'; import { mergeTimeStore } from '@shared/utils/time-store'; @@ -45,7 +46,7 @@ export const getCurrentHostTime = async (host: string): Promise => { const store = await getTotalActivity(); const currentDate = getIsoDate(new Date()); - return (store[currentDate] as any)?.[host] ?? 0; + return store[currentDate]?.[host as HostName] ?? 0; }; export const setTotalDailyHostTime = async ({ @@ -53,8 +54,8 @@ export const setTotalDailyHostTime = async ({ host, duration, }: { - date: string; - host: string; + date: IsoDate; + host: HostName; duration: number; }) => { const store = await getTotalActivity(); diff --git a/src/shared/db/types.ts b/src/shared/db/types.ts index a046643..4a3bc30 100644 --- a/src/shared/db/types.ts +++ b/src/shared/db/types.ts @@ -1,6 +1,8 @@ -import { IdleState, Tab } from '../services/browser-api/types'; +import { IdleState, Tab } from '@shared/services/browser-api/types'; +import { HostName, IsoDate } from '@shared/types'; -export type TimeStore = Record>; +export type ActivitySummaryByHostname = Record; +export type TimeStore = Record; export interface TimelineRecord { tabId: number; diff --git a/src/shared/libs/ChartJs.tsx b/src/shared/libs/ChartJs.tsx new file mode 100644 index 0000000..a450546 --- /dev/null +++ b/src/shared/libs/ChartJs.tsx @@ -0,0 +1,4 @@ +import 'chart.js/auto'; + +export * from 'react-chartjs-2'; +export { ChartOptions } from 'chart.js'; diff --git a/src/shared/preferences/index.ts b/src/shared/preferences/index.ts index c1666fd..41b9917 100644 --- a/src/shared/preferences/index.ts +++ b/src/shared/preferences/index.ts @@ -1,12 +1,16 @@ -import { Preferences } from '../db/types'; +import { DeepReadonly } from 'utility-types'; -export const DEFAULT_PREFERENCES: Preferences = { +import { Preferences } from '@shared/db/types'; + +export const DEFAULT_PREFERENCES: DeepReadonly = { ignoredHosts: [], limits: {}, displayTimeOnBadge: true, }; -export const setSettings = async (settings: Partial) => { +export const setSettings = async ( + settings: Partial>, +) => { const currentSettings = await getSettings(); await chrome.storage.local.set({ @@ -14,10 +18,10 @@ export const setSettings = async (settings: Partial) => { }); }; -export const getSettings = async () => { +export const getSettings = async (): Promise> => { const { settings = {} } = await chrome.storage.local.get('settings'); return { ...DEFAULT_PREFERENCES, ...settings, - } as Preferences; + }; }; diff --git a/src/shared/services/_spec/i18n.spec.ts b/src/shared/services/_spec/i18n.spec.ts new file mode 100644 index 0000000..d64df73 --- /dev/null +++ b/src/shared/services/_spec/i18n.spec.ts @@ -0,0 +1,36 @@ +import { formatI18NMessage } from '../i18n'; + +describe('formatI18NMessage', () => { + test('should format empty message', () => { + expect(formatI18NMessage('', {})).toBe(''); + }); + + test('should format message without placeholders', () => { + expect(formatI18NMessage('Hello, world!', {})).toBe('Hello, world!'); + }); + + test('should format message with placeholders', () => { + expect(formatI18NMessage('Hello, $name$', { name: 'world' })).toBe( + 'Hello, world', + ); + }); + + test('should format message with multiple placeholders', () => { + expect( + formatI18NMessage('Hello, $name$, $greeting$', { + name: 'world', + greeting: 'good morning', + }), + ).toBe('Hello, world, good morning'); + }); + + test('should format message with missing placeholders', () => { + expect(formatI18NMessage('Hello, $name$', {})).toBe('Hello, undefined'); + }); + + test('should format message with missing placeholders', () => { + expect(formatI18NMessage('Hello, $name$', { greeting: 'world' })).toBe( + 'Hello, undefined', + ); + }); +}); diff --git a/src/shared/services/i18n.ts b/src/shared/services/i18n.ts new file mode 100644 index 0000000..b95362a --- /dev/null +++ b/src/shared/services/i18n.ts @@ -0,0 +1,66 @@ +type I18n = typeof import('../../../static/_locales/en/messages.json'); +type I18NPlaceholder = I18n[T] extends { + placeholders: infer P; +} + ? [{ [K in keyof P]: string }] + : []; + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic import +const fallback = require('../../../static/_locales/en/messages.json') as I18n; + +export const i18n = ( + message: T, + ...placeholders: I18NPlaceholder +) => { + const values = placeholders[0]; + if (chrome?.i18n?.getMessage) { + return chrome.i18n.getMessage( + message, + values ? Object.values(values) : undefined, + ); + } + + const messageTemplate = fallback[message].message; + if (!values) { + return messageTemplate; + } + + return formatI18NMessage(messageTemplate, values); +}; + +export function formatI18NMessage( + message: string, + values: Record, +) { + let formattedString = ''; + let lastIndex = 0; + while (true) { + const openIndex = message.indexOf('$', lastIndex); + if (openIndex === -1) { + formattedString += message.slice(lastIndex); + break; + } + + const closeIndex = message.indexOf('$', openIndex + 1); + if (closeIndex === -1) { + formattedString += message.slice(lastIndex); + break; + } + + const placeholderName = message.slice(openIndex + 1, closeIndex); + formattedString += message.slice(lastIndex, openIndex); + formattedString += values[placeholderName as keyof typeof values]; + lastIndex = closeIndex + 1; + } + + return formattedString; +} + +// export const i18n = new Proxy( +// {}, +// { +// get: (_target, prop) => { +// return (values) => getI18nMessage(prop as string, values); +// }, +// }, +// ) as { [K in keyof I18n]: (...args: I18NPlaceholder) => string }; diff --git a/src/shared/tables/activity-timeline.ts b/src/shared/tables/activity-timeline.ts index 937eb78..06d8bc5 100644 --- a/src/shared/tables/activity-timeline.ts +++ b/src/shared/tables/activity-timeline.ts @@ -11,7 +11,7 @@ export async function getActivityTimeline(isoDate: string) { return db.getAllFromIndex(TimeTrackerStoreTables.Timeline, 'date', isoDate); } -export async function getAllActivityTimeline() { +export async function getFullActivityTimeline() { const db = await connect(); return db.getAll(TimeTrackerStoreTables.Timeline); diff --git a/src/shared/tables/state.ts b/src/shared/tables/state.ts index 0f68008..c08d869 100644 --- a/src/shared/tables/state.ts +++ b/src/shared/tables/state.ts @@ -1,11 +1,15 @@ +import { DeepReadonly } from 'utility-types'; + import { connect, TimeTrackerStoreStateTableKeys, TimeTrackerStoreTables, -} from '../db/idb'; -import { ActiveTabState, TimelineRecord } from '../db/types'; +} from '@shared/db/idb'; +import { ActiveTabState, TimelineRecord } from '@shared/db/types'; -export async function setActiveTabRecord(val: TimelineRecord | null) { +export async function setActiveTabRecord( + val: DeepReadonly | null, +) { const db = await connect(); await db.put( @@ -20,7 +24,7 @@ export async function getActiveTabRecord() { return (await db.get( TimeTrackerStoreTables.State, TimeTrackerStoreStateTableKeys.ActiveTab, - )) as TimelineRecord | null; + )) as DeepReadonly | null; } export async function getTabsState() { @@ -29,10 +33,10 @@ export async function getTabsState() { return (await db.get( TimeTrackerStoreTables.State, TimeTrackerStoreStateTableKeys.AppState, - )) as ActiveTabState; + )) as DeepReadonly; } -export async function setTabsState(val: ActiveTabState) { +export async function setTabsState(val: DeepReadonly) { const db = await connect(); await db.put( diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..750716b --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,2 @@ +export type IsoDate = string & { readonly __iso_date: unique symbol }; +export type HostName = string & { readonly __host_name: unique symbol }; diff --git a/src/shared/utils/date.ts b/src/shared/utils/date.ts index 5420b05..68eea36 100644 --- a/src/shared/utils/date.ts +++ b/src/shared/utils/date.ts @@ -1,3 +1,5 @@ +import { IsoDate } from '@shared/types'; + const SECOND_IN_MS = 1000; const MINUTE_IN_MS = 60 * SECOND_IN_MS; const HOUR_IN_MS = 60 * MINUTE_IN_MS; @@ -6,16 +8,22 @@ const WEEK_IN_MS = 7 * DAY_IN_MS; const MONTH_IN_MS = 30 * DAY_IN_MS; const YEAR_IN_MS = 365 * DAY_IN_MS; -export const getIsoDate = (date: Date = new Date()) => { +export const getIsoDate = (date: Date = new Date()): IsoDate => { const year = date.getFullYear(); const month = (date.getMonth() + 1).toString().padStart(2, '0'); const day = date.getDate().toString().padStart(2, '0'); - const isoDate = `${year}-${month}-${day}`; + const isoDate = `${year}-${month}-${day}` as IsoDate; return isoDate; }; +export function assertIsIsoDate(date: string): asserts date is IsoDate { + if (!date.match(/^\d{4}-\d{2}-\d{2}$/)) { + throw new Error('assertIsIsoDate: Invalid date format'); + } +} + export const getMinutesInMs = (number: number) => number * MINUTE_IN_MS; export const getHoursInMs = (number: number) => number * HOUR_IN_MS; export const getDaysInMs = (number: number) => number * DAY_IN_MS; @@ -72,7 +80,8 @@ export const getTimeWithoutSeconds = (number: number) => { const DEFAULT_DATE_TRANSFORMER = (date: Date) => new Date(date); -export const get7DaysPriorDate = < +export const generatePrior7DaysDates = < + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- T is a generic T extends (date: Date) => any = (date: Date) => Date, >( date: Date, @@ -85,6 +94,7 @@ export const get7DaysPriorDate = < }); export const rangeDaysAgo = < + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- T is a generic DateTransformer extends (date: Date) => any = (date: Date) => Date, >({ from: date, @@ -125,3 +135,39 @@ export const presentHoursOrMinutesFromMinutes = (minutes: number) => { return `${hoursRounded - 0.5}h`; }; + +export const today = () => new Date(); +export const yesterday = () => { + const date = new Date(); + date.setDate(date.getDate() - 1); + + return date; +}; +export const tomorrow = () => { + const date = new Date(); + date.setDate(date.getDate() + 1); + + return date; +}; + +export const daysAgo = (date: Date, days: number) => { + const daysAgoDate = new Date(date); + daysAgoDate.setDate(daysAgoDate.getDate() - days); + + return daysAgoDate; +}; + +export const weeksBefore = (date: Date, weeks: number) => { + const weeksBeforeDate = new Date(date); + weeksBeforeDate.setDate(weeksBeforeDate.getDate() - weeks * 7); + + return weeksBeforeDate; +}; + +export class PredefinedIsoDates { + static today = getIsoDate(today()); + static yesterday = getIsoDate(yesterday()); + static tomorrow = getIsoDate(tomorrow()); + static lastWeek = getIsoDate(weeksBefore(today(), 1)); + static lastMonth = getIsoDate(daysAgo(today(), 30)); +} diff --git a/src/shared/utils/spec/date.spec.ts b/src/shared/utils/spec/date.spec.ts index 5506764..ac98184 100644 --- a/src/shared/utils/spec/date.spec.ts +++ b/src/shared/utils/spec/date.spec.ts @@ -5,7 +5,7 @@ import { getDaysInMs, getTimeFromMs, getTimeWithoutSeconds, - get7DaysPriorDate, + generatePrior7DaysDates, getDatesWeekSundayDate, } from '../date'; @@ -109,14 +109,14 @@ describe('get7DaysPriorDate', () => { it('should return an array of 7 dates', () => { const date = new Date('2020-01-01T00:00:00.000Z'); - expect(get7DaysPriorDate(date)).toHaveLength(7); + expect(generatePrior7DaysDates(date)).toHaveLength(7); }); it('should return an array of 7 dates starting from the given date', () => { const date = new Date('2020-01-01T00:00:00.000Z'); - expect(get7DaysPriorDate(date)[0]).toEqual(date); - expect(get7DaysPriorDate(date)[6]).toEqual( + expect(generatePrior7DaysDates(date)[0]).toEqual(date); + expect(generatePrior7DaysDates(date)[6]).toEqual( new Date('2019-12-26T00:00:00.000Z'), ); }); diff --git a/src/shared/utils/time-store.ts b/src/shared/utils/time-store.ts index 030b414..3324811 100644 --- a/src/shared/utils/time-store.ts +++ b/src/shared/utils/time-store.ts @@ -1,17 +1,23 @@ import { TimeStore } from '@shared/db/types'; -import { get7DaysPriorDate, getIsoDate } from '@shared/utils/date'; +import { generatePrior7DaysDates, getIsoDate } from '@shared/utils/date'; export const mergeTimeStore = ( storeA: TimeStore, storeB: TimeStore, ): TimeStore => { - const allStoreKeys = Object.keys({ ...(storeA || {}), ...(storeB || {}) }); + const allStoreKeys = Object.keys({ + ...(storeA || {}), + ...(storeB || {}), + }) as Array; const mergedStore: TimeStore = {}; for (const key of allStoreKeys) { const storeAValue = storeA[key]; const storeBValue = storeB[key]; - const storeValueKeys = Object.keys({ ...storeAValue, ...storeBValue }); + const storeValueKeys = Object.keys({ + ...storeAValue, + ...storeBValue, + }) as Array; const mergedValue: Record = {}; for (const storeValueKey of storeValueKeys) { @@ -37,7 +43,7 @@ export const getTotalDailyActivity = (store: TimeStore, date: Date) => { }; export const getTotalWeeklyActivity = (store: TimeStore, date = new Date()) => - get7DaysPriorDate(date).reduce( + generatePrior7DaysDates(date).reduce( (sum, date) => sum + getTotalDailyActivity(store, date), 0, ); diff --git a/src/shared/utils/url.ts b/src/shared/utils/url.ts index f202258..e9d9cba 100644 --- a/src/shared/utils/url.ts +++ b/src/shared/utils/url.ts @@ -1,9 +1,11 @@ +import { HostName } from '@shared/types'; + import { assert } from './guards'; -export const getHostNameFromUrl = (url: string) => { +export const getHostNameFromUrl = (url: string): HostName => { const { hostname } = new URL(url); - return hostname || url; + return (hostname || url) as HostName; }; const BROWSER_URL_PREFIX = [ diff --git a/static/_locales/de/messages.json b/static/_locales/de/messages.json new file mode 100644 index 0000000..ff6adf9 --- /dev/null +++ b/static/_locales/de/messages.json @@ -0,0 +1,164 @@ +{ + "error": { + "message": "Fehler: $details$", + "placeholders": { + "details": { + "content": "$1" + } + } + }, + "TimeUsagePanel_7DayAverageTime": { + "message": "7-Tage-Durchschnitt: $time$", + "placeholders": { + "time": { + "content": "$1" + } + } + }, + "TimeUsagePanel_LowerSpan": { + "message": "niedriger" + }, + "TimeUsagePanel_HigherSpan": { + "message": "höher" + }, + "TimeUsagePanel_ThanAverageSpan": { + "message": "als der Durchschnitt" + }, + "ActivityPageDailyActivityTab_TimeUsagePanelHeader": { + "message": "Tägliche Nutzung" + }, + "ActivityPageDailyActivityTab_EmptyActivity": { + "message": "Hier gibt es noch nichts zu sehen..." + }, + "ActivityPageDailyActivityTab_ActivityTimelineHeader": { + "message": "Aktivitätsverlauf" + }, + "ActivityPageDailyActivityTab_ActivityTimelineHeaderOnHostname": { + "message": "Aktivitätsverlauf auf $hostname$", + "placeholders": { + "hostname": { + "content": "$1" + } + } + }, + "ActivityPageDailyActivityTab_TopFiveActiveWebsites": { + "message": "Top 5 aktive Seiten am $date$", + "placeholders": { + "date": { + "content": "$1" + } + } + }, + "ActivityTable_NoDataAvailable": { + "message": "Keine Daten verfügbar" + }, + "ActivityTable_ClickToFilterHint": { + "message": "Klicken Sie auf den Namen der Website, um die Statistiken für diese Website anzuzeigen." + }, + "ActivityTable_ClickToIgnoreHintPart1": { + "message": "Klicken Sie auf das " + }, + "ActivityTable_ClickToIgnoreHintPart2": { + "message": "Symbol, um diese Website zu verbergen und zu ignorieren. Sie können Ihre Entscheidung jederzeit im Bereich Ignorierte Domains ändern." + }, + "ActivityTimelineChart_NoData": { + "message": "Keine Verlaufsdaten für diesen Tag verfügbar" + }, + "ActivityPageWeeklyTab_TimeUsagePanelHeader": { + "message": "Durchschnittliche tägliche Aktivität" + }, + "ActivityPageWeeklyTab_WebsiteActivityTableHeader": { + "message": "Websites dieser Woche" + }, + "ActivityPageWeeklyTab_WeeklyWebsiteActivityChartTitle": { + "message": "Aktivität auf $domain$ pro Tag", + "placeholders": { + "domain": { + "content": "$1" + } + } + }, + "ActivityPageWeeklyTab_AllWebsites": { + "message": "Alle Websites" + }, + "ActivityPageDailyActivityTab_WebsiteActivityTableHeader": { + "message": "Websites an diesem Tag" + }, + "WeeklyWebsiteActivityChart_ChartLabel": { + "message": "Wöchentliche Aktivität" + }, + "OverallActivityCalendar_Header": { + "message": "Gesamtaktivitätskarte" + }, + "OverallPage_TimeUsagePanelHeader": { + "message": "Heute besucht" + }, + "OverallPage_ActivityTimelineInLast6Hours": { + "message": "Aktivität in den letzten 6 Stunden" + }, + "OverallPage_TimeUsagePanelOnActiveWebsite": { + "message": "Besucht auf $hostname$", + "placeholders": { + "hostname": { + "content": "$1" + } + } + }, + "PreferencesPage_Header": { + "message": "Einstellungen" + }, + "Backup_Header": { + "message": "Sicherung" + }, + "Backup_OptionCSV": { + "message": "Exportieren nach CSV" + }, + "Backup_OptionJSON": { + "message": "Exportieren nach JSON" + }, + "DisplayTimeOnBadge_OptionToEnable": { + "message": "Aktive Zeit auf dem Erweiterungssymbol anzeigen" + }, + "ThemeSelector_Header": { + "message": "Thema" + }, + "ThemeSelector_OptionLight": { + "message": "Hell" + }, + "ThemeSelector_OptionDark": { + "message": "Dunkel" + }, + "ThemeSelector_OptionAuto": { + "message": "Automatisch" + }, + "LimitsSetting_Header": { + "message": "Grenzen" + }, + "LimitsSetting_FeatureDescription": { + "message": "Fügen Sie Zeitlimits für Ihre tägliche Webaktivität auf bestimmten Websites hinzu. Sobald das Limit erreicht ist, wird die Website schwarz-weiß angezeigt." + }, + "LimitsSetting_DomainInputLabel": { + "message": "Domain" + }, + "LimitsSetting_TimeLimitInputLabel": { + "message": "Zeitlimit" + }, + "LimitsSetting_AddButton": { + "message": "Hinzufügen" + }, + "IgnoredDomainSetting_Header": { + "message": "Ignorierte Domains" + }, + "IgnoredDomainSetting_FeatureDescription": { + "message": "Bestimmte Websites in Ihrem Aktivitätsverlauf ausblenden und ignorieren." + }, + "IgnoredDomainSetting_DomainInputLabel": { + "message": "Domain" + }, + "IgnoredDomainSetting_AddButton": { + "message": "Hinzufügen" + }, + "IgnoredDomainSetting_ViewAllIgnoredDomainsLink": { + "message": "Alle ignorierten Domains anzeigen" + } +} diff --git a/static/_locales/en/messages.json b/static/_locales/en/messages.json new file mode 100644 index 0000000..5e8cc52 --- /dev/null +++ b/static/_locales/en/messages.json @@ -0,0 +1,217 @@ +{ + "error": { + "message": "Error: $details$", + "description": "Generic error template. Expects error parameter to be passed in.", + "placeholders": { + "details": { + "content": "$1", + "example": "Failed to fetch RSS feed." + } + } + }, + "TimeUsagePanel_7DayAverageTime": { + "message": "7 day average: $time$", + "description": "7 day average 2hr 30m", + "placeholders": { + "time": { + "content": "$1", + "example": "1h 30m" + } + } + }, + "TimeUsagePanel_LowerSpan": { + "message": "lower", + "description": "Text in between two time spans", + "example": "20% lower than" + }, + "TimeUsagePanel_HigherSpan": { + "message": "higher", + "description": "Text in between two time spans", + "example": "20% higher than" + }, + "TimeUsagePanel_ThanAverageSpan": { + "message": "than average", + "description": "Text in between two time spans", + "example": "20% higher than average" + }, + "ActivityPageDailyActivityTab_TimeUsagePanelHeader": { + "message": "Daily usage", + "description": "Header for daily usage panel" + }, + "ActivityPageDailyActivityTab_EmptyActivity": { + "message": "Nothing to see here yet...", + "description": "Empty state message for daily activity tab" + }, + "ActivityPageDailyActivityTab_ActivityTimelineHeader": { + "message": "Activity Timeline", + "description": "Header for activity timeline" + }, + "ActivityPageDailyActivityTab_ActivityTimelineHeaderOnHostname": { + "message": "Activity Timeline On $hostname$", + "description": "Header for activity timeline", + "placeholders": { + "hostname": { + "content": "$1", + "example": "example.com" + } + } + }, + "ActivityPageDailyActivityTab_TopFiveActiveWebsites": { + "message": "Top 5 Active Sites on $date$", + "description": "Top 5 Active Sites on 2021-01-01", + "placeholders": { + "date": { + "content": "$1", + "example": "2021-01-01" + } + } + }, + "ActivityTable_NoDataAvailable": { + "message": "No data available", + "description": "Message displayed when no data is available for the table" + }, + "ActivityTable_ClickToFilterHint": { + "message": "Click on the website name to view stats for this website.", + "description": "Hint displayed when hovering over the website name in the table" + }, + "ActivityTable_ClickToIgnoreHintPart1": { + "message": "Click on the ", + "description": "Part 1 of the hint displayed when hovering over the website name in the table" + }, + "ActivityTable_ClickToIgnoreHintPart2": { + "message": "icon to hide and ignore this website. You can always change your decision it in the Ignored domains section.", + "description": "Part 2 of the hint displayed when hovering over the website name in the table" + }, + "ActivityTimelineChart_NoData": { + "message": "Don't have timeline data for this day", + "description": "Message displayed when no timeline data is available for the day" + }, + "ActivityPageWeeklyTab_TimeUsagePanelHeader": { + "message": "Average Daily Activity", + "description": "Header for weekly usage panel" + }, + "ActivityPageWeeklyTab_WebsiteActivityTableHeader": { + "message": "Websites This Week", + "description": "Header for website activity table" + }, + "ActivityPageWeeklyTab_WeeklyWebsiteActivityChartTitle": { + "message": "Activity on $domain$ per day", + "description": "Activity on example.com per day", + "placeholders": { + "domain": { + "content": "$1", + "example": "example.com" + } + } + }, + "ActivityPageWeeklyTab_AllWebsites": { + "message": "All Websites", + "description": "All Websites" + }, + "ActivityPageDailyActivityTab_WebsiteActivityTableHeader": { + "message": "Websites This Day", + "description": "Header for website activity table" + }, + "WeeklyWebsiteActivityChart_ChartLabel": { + "message": "Weekly activity", + "description": "Label for weekly activity chart" + }, + "OverallActivityCalendar_Header": { + "message": "Overall Activity Map", + "description": "Header for overall activity calendar" + }, + "OverallPage_TimeUsagePanelHeader": { + "message": "Surfed Today", + "description": "Header for overall usage panel" + }, + "OverallPage_ActivityTimelineInLast6Hours": { + "message": "Activity in last 6 hours", + "description": "Header for activity timeline" + }, + "OverallPage_TimeUsagePanelOnActiveWebsite": { + "message": "Surfed on $hostname$", + "description": "Header for overall usage panel", + "placeholders": { + "hostname": { + "content": "$1", + "example": "example.com" + } + } + }, + "PreferencesPage_Header": { + "message": "Preferences", + "description": "Header for preferences page" + }, + "Backup_Header": { + "message": "Backup", + "description": "Header for backup page" + }, + "Backup_OptionCSV": { + "message": "Export to CSV", + "description": "Option to export data to CSV" + }, + "Backup_OptionJSON": { + "message": "Export to JSON", + "description": "Option to export data to JSON" + }, + "DisplayTimeOnBadge_OptionToEnable": { + "message": "Display active time on the extension icon", + "description": "Option to enable display of active time on the extension icon" + }, + "ThemeSelector_Header": { + "message": "Theme", + "description": "Header for theme selector" + }, + "ThemeSelector_OptionLight": { + "message": "Light", + "description": "Option for light theme" + }, + "ThemeSelector_OptionDark": { + "message": "Dark", + "description": "Option for dark theme" + }, + "ThemeSelector_OptionAuto": { + "message": "Auto", + "description": "Option for dark theme" + }, + "LimitsSetting_Header": { + "message": "Limits", + "description": "Header for limits setting" + }, + "LimitsSetting_FeatureDescription": { + "message": "Add time limits to your daily web activity on specific websites. Once limit reached website will become black and white.", + "description": "Description for limits setting" + }, + "LimitsSetting_DomainInputLabel": { + "message": "Domain", + "description": "Domain label for input" + }, + "LimitsSetting_TimeLimitInputLabel": { + "message": "Time limit", + "description": "Time limit label for input" + }, + "LimitsSetting_AddButton": { + "message": "Add", + "description": "Add button" + }, + "IgnoredDomainSetting_Header": { + "message": "Ignored Domains", + "description": "Header for ignored domains setting" + }, + "IgnoredDomainSetting_FeatureDescription": { + "message": "Hide and ignore specific websites from your activity timeline.", + "description": "Description for ignored domains setting" + }, + "IgnoredDomainSetting_DomainInputLabel": { + "message": "Domain", + "description": "Domain label for input" + }, + "IgnoredDomainSetting_AddButton": { + "message": "Add", + "description": "Add button" + }, + "IgnoredDomainSetting_ViewAllIgnoredDomainsLink": { + "message": "View all ignored domains", + "description": "Link to view all ignored domains" + } +} diff --git a/static/_locales/it/messages.json b/static/_locales/it/messages.json new file mode 100644 index 0000000..4377fff --- /dev/null +++ b/static/_locales/it/messages.json @@ -0,0 +1,164 @@ +{ + "error": { + "message": "Errore: $details$", + "placeholders": { + "details": { + "content": "$1" + } + } + }, + "TimeUsagePanel_7DayAverageTime": { + "message": "Media 7 giorni: $time$", + "placeholders": { + "time": { + "content": "$1" + } + } + }, + "TimeUsagePanel_LowerSpan": { + "message": "inferiore" + }, + "TimeUsagePanel_HigherSpan": { + "message": "superiore" + }, + "TimeUsagePanel_ThanAverageSpan": { + "message": "rispetto alla media" + }, + "ActivityPageDailyActivityTab_TimeUsagePanelHeader": { + "message": "Utilizzo giornaliero" + }, + "ActivityPageDailyActivityTab_EmptyActivity": { + "message": "Niente da vedere qui per ora..." + }, + "ActivityPageDailyActivityTab_ActivityTimelineHeader": { + "message": "Cronologia Attività" + }, + "ActivityPageDailyActivityTab_ActivityTimelineHeaderOnHostname": { + "message": "Cronologia Attività su $hostname$", + "placeholders": { + "hostname": { + "content": "$1" + } + } + }, + "ActivityPageDailyActivityTab_TopFiveActiveWebsites": { + "message": "Top 5 Siti Attivi il $date$", + "placeholders": { + "date": { + "content": "$1" + } + } + }, + "ActivityTable_NoDataAvailable": { + "message": "Nessun dato disponibile" + }, + "ActivityTable_ClickToFilterHint": { + "message": "Clicca sul nome del sito per visualizzare le statistiche per questo sito." + }, + "ActivityTable_ClickToIgnoreHintPart1": { + "message": "Clicca sul " + }, + "ActivityTable_ClickToIgnoreHintPart2": { + "message": "icona per nascondere e ignorare questo sito. Puoi sempre cambiare la tua decisione nella sezione Domini ignorati." + }, + "ActivityTimelineChart_NoData": { + "message": "Non ci sono dati di cronologia per questo giorno" + }, + "ActivityPageWeeklyTab_TimeUsagePanelHeader": { + "message": "Attività Giornaliera Media" + }, + "ActivityPageWeeklyTab_WebsiteActivityTableHeader": { + "message": "Siti Web Questa Settimana" + }, + "ActivityPageWeeklyTab_WeeklyWebsiteActivityChartTitle": { + "message": "Attività su $domain$ per giorno", + "placeholders": { + "domain": { + "content": "$1" + } + } + }, + "ActivityPageWeeklyTab_AllWebsites": { + "message": "Tutti i Siti Web" + }, + "ActivityPageDailyActivityTab_WebsiteActivityTableHeader": { + "message": "Siti Web Questo Giorno" + }, + "WeeklyWebsiteActivityChart_ChartLabel": { + "message": "Attività settimanale" + }, + "OverallActivityCalendar_Header": { + "message": "Mappa dell'Attività Generale" + }, + "OverallPage_TimeUsagePanelHeader": { + "message": "Navigato Oggi" + }, + "OverallPage_ActivityTimelineInLast6Hours": { + "message": "Attività nelle ultime 6 ore" + }, + "OverallPage_TimeUsagePanelOnActiveWebsite": { + "message": "Navigato su $hostname$", + "placeholders": { + "hostname": { + "content": "$1" + } + } + }, + "PreferencesPage_Header": { + "message": "Preferenze" + }, + "Backup_Header": { + "message": "Backup" + }, + "Backup_OptionCSV": { + "message": "Esporta in CSV" + }, + "Backup_OptionJSON": { + "message": "Esporta in JSON" + }, + "DisplayTimeOnBadge_OptionToEnable": { + "message": "Mostra il tempo attivo sull'icona dell'estensione" + }, + "ThemeSelector_Header": { + "message": "Tema" + }, + "ThemeSelector_OptionLight": { + "message": "Chiaro" + }, + "ThemeSelector_OptionDark": { + "message": "Scuro" + }, + "ThemeSelector_OptionAuto": { + "message": "Automatico" + }, + "LimitsSetting_Header": { + "message": "Limiti" + }, + "LimitsSetting_FeatureDescription": { + "message": "Aggiungi limiti di tempo alla tua attività web giornaliera su siti specifici. Una volta raggiunto il limite, il sito diventerà in bianco e nero." + }, + "LimitsSetting_DomainInputLabel": { + "message": "Dominio" + }, + "LimitsSetting_TimeLimitInputLabel": { + "message": "Limite di tempo" + }, + "LimitsSetting_AddButton": { + "message": "Aggiungi" + }, + "IgnoredDomainSetting_Header": { + "message": "Domini Ignorati" + }, + "IgnoredDomainSetting_FeatureDescription": { + "message": "Nascondi e ignora specifici siti web dalla tua cronologia attività." + }, + "IgnoredDomainSetting_DomainInputLabel": { + "message": "Dominio" + }, + "IgnoredDomainSetting_AddButton": { + "message": "Aggiungi" + }, + "IgnoredDomainSetting_ViewAllIgnoredDomainsLink": { + "message": "Visualizza tutti i domini ignorati" + } +} diff --git a/static/_locales/ru/messages.json b/static/_locales/ru/messages.json new file mode 100644 index 0000000..188b9d9 --- /dev/null +++ b/static/_locales/ru/messages.json @@ -0,0 +1,164 @@ +{ + "error": { + "message": "Ошибка: $details$", + "placeholders": { + "details": { + "content": "$1" + } + } + }, + "TimeUsagePanel_7DayAverageTime": { + "message": "В среднем за за 7 дней: $time$", + "placeholders": { + "time": { + "content": "$1" + } + } + }, + "TimeUsagePanel_LowerSpan": { + "message": "меньше" + }, + "TimeUsagePanel_HigherSpan": { + "message": "больше" + }, + "TimeUsagePanel_ThanAverageSpan": { + "message": "чем в среднем" + }, + "ActivityPageDailyActivityTab_TimeUsagePanelHeader": { + "message": "Ежедневное использование" + }, + "ActivityPageDailyActivityTab_EmptyActivity": { + "message": "Пока здесь нечего смотреть..." + }, + "ActivityPageDailyActivityTab_ActivityTimelineHeader": { + "message": "Лента активности" + }, + "ActivityPageDailyActivityTab_ActivityTimelineHeaderOnHostname": { + "message": "Лента активности на $hostname$", + "placeholders": { + "hostname": { + "content": "$1" + } + } + }, + "ActivityPageDailyActivityTab_TopFiveActiveWebsites": { + "message": "Топ 5 активных сайтов за $date$", + "placeholders": { + "date": { + "content": "$1" + } + } + }, + "ActivityTable_NoDataAvailable": { + "message": "Нет доступных данных" + }, + "ActivityTable_ClickToFilterHint": { + "message": "Нажмите на название сайта, чтобы просмотреть статистику для этого сайта." + }, + "ActivityTable_ClickToIgnoreHintPart1": { + "message": "Нажмите на " + }, + "ActivityTable_ClickToIgnoreHintPart2": { + "message": "иконку, чтобы скрыть и игнорировать этот сайт. Вы всегда можете изменить свое решение в разделе Игнорируемые домены." + }, + "ActivityTimelineChart_NoData": { + "message": "Нет данных по ленте активности на этот день" + }, + "ActivityPageWeeklyTab_TimeUsagePanelHeader": { + "message": "Средняя дневная активность" + }, + "ActivityPageWeeklyTab_WebsiteActivityTableHeader": { + "message": "Сайты на этой неделе" + }, + "ActivityPageWeeklyTab_WeeklyWebsiteActivityChartTitle": { + "message": "Активность на $domain$ по дням", + "placeholders": { + "domain": { + "content": "$1" + } + } + }, + "ActivityPageWeeklyTab_AllWebsites": { + "message": "Все сайты" + }, + "ActivityPageDailyActivityTab_WebsiteActivityTableHeader": { + "message": "Сайты в этот день" + }, + "WeeklyWebsiteActivityChart_ChartLabel": { + "message": "Еженедельная активность" + }, + "OverallActivityCalendar_Header": { + "message": "Общая карта активности" + }, + "OverallPage_TimeUsagePanelHeader": { + "message": "Посещено сегодня" + }, + "OverallPage_ActivityTimelineInLast6Hours": { + "message": "Активность за последние 6 часов" + }, + "OverallPage_TimeUsagePanelOnActiveWebsite": { + "message": "Посещено на $hostname$", + "placeholders": { + "hostname": { + "content": "$1" + } + } + }, + "PreferencesPage_Header": { + "message": "Настройки" + }, + "Backup_Header": { + "message": "Резервное копирование" + }, + "Backup_OptionCSV": { + "message": "Экспорт в CSV" + }, + "Backup_OptionJSON": { + "message": "Экспорт в JSON" + }, + "DisplayTimeOnBadge_OptionToEnable": { + "message": "Отображать активное время на значке расширения" + }, + "ThemeSelector_Header": { + "message": "Тема" + }, + "ThemeSelector_OptionLight": { + "message": "Светлая" + }, + "ThemeSelector_OptionDark": { + "message": "Темная" + }, + "ThemeSelector_OptionAuto": { + "message": "Автоматическая" + }, + "LimitsSetting_Header": { + "message": "Ограничения" + }, + "LimitsSetting_FeatureDescription": { + "message": "Добавьте ограничения по времени на ежедневную веб-активность на конкретных сайтах. После достижения лимита сайт станет черно-белым." + }, + "LimitsSetting_DomainInputLabel": { + "message": "Домен" + }, + "LimitsSetting_TimeLimitInputLabel": { + "message": "Лимит времени" + }, + "LimitsSetting_AddButton": { + "message": "Добавить" + }, + "IgnoredDomainSetting_Header": { + "message": "Игнорируемые домены" + }, + "IgnoredDomainSetting_FeatureDescription": { + "message": "Скрывайте и игнорируйте конкретные сайты из ленты вашей активности." + }, + "IgnoredDomainSetting_DomainInputLabel": { + "message": "Домен" + }, + "IgnoredDomainSetting_AddButton": { + "message": "Добавить" + }, + "IgnoredDomainSetting_ViewAllIgnoredDomainsLink": { + "message": "Просмотреть все игнорируемые домены" + } +} diff --git a/static/manifest.json b/static/manifest.json index ec7b7de..d024cfe 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,6 +1,7 @@ { "name": "BroTime", "manifest_version": 3, + "default_locale": "en", "action": { "default_popup": "popup.html", "default_title": "BroTime", diff --git a/tsconfig.json b/tsconfig.json index 47e8546..2444c0d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,10 +3,16 @@ "outDir": "./dist/", "baseUrl": "./", "paths": { - "@shared/*": ["src/shared/*"] + "@shared/*": ["src/shared/*"], + "@popup/*": ["src/popup/*"], + "@background/*": ["src/background/*"], + "@content/*": ["src/content/*"], + "@dashboard/*": ["src/dashboard/*"] }, "allowJs": true, "sourceMap": true, + "noImplicitAny": true, + "strictFunctionTypes": true, "strict": true, "noUncheckedIndexedAccess": true, "target": "ES2018", @@ -15,6 +21,8 @@ "moduleResolution": "node", "jsx": "react", "jsxFactory": "React.createElement", - "jsxFragmentFactory": "React.Fragment" + "jsxFragmentFactory": "React.Fragment", + "esModuleInterop": true, + "resolveJsonModule": true } }