From 29904867ef0fb634511bb78139b5eb1f395534ea Mon Sep 17 00:00:00 2001 From: CrawlerCode <41094392+CrawlerCode@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:24:31 +0200 Subject: [PATCH] feat: Add time entry charts --- package.json | 1 + pnpm-lock.yaml | 283 ++++++++++++++++++ src/components/form/DateField.tsx | 28 +- .../time-entry/TimeEntryStatsCard.tsx | 112 +++++++ .../time-entry/TimeEntryWeekOverview.tsx | 26 +- .../time-entry/charts/TimeByActivityChart.tsx | 86 ++++++ .../time-entry/charts/TimeByProjectChart.tsx | 68 +++++ src/components/ui/chart.tsx | 275 +++++++++++++++++ src/index.css | 4 + src/lang/de.json | 3 + src/lang/en.json | 3 + src/lang/fr.json | 3 + src/lang/ru.json | 3 + src/routes/time.tsx | 32 +- 14 files changed, 896 insertions(+), 31 deletions(-) create mode 100644 src/components/time-entry/TimeEntryStatsCard.tsx create mode 100644 src/components/time-entry/charts/TimeByActivityChart.tsx create mode 100644 src/components/time-entry/charts/TimeByProjectChart.tsx create mode 100644 src/components/ui/chart.tsx diff --git a/package.json b/package.json index ff347f2..d331bab 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "react-day-picker": "^9.14.0", "react-dom": "^19.2.5", "react-intl": "^8.1.4", + "recharts": "3.8.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "usehooks-ts": "^3.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0899d4..063d3fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: react-intl: specifier: ^8.1.4 version: 8.1.4(@types/react@19.2.14)(react@19.2.5)(typescript@6.0.2) + recharts: + specifier: 3.8.0 + version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(redux@5.0.1) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1098,6 +1101,17 @@ packages: resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} engines: {node: '>=12'} + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/binding-android-arm64@1.0.0-rc.15': resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1413,6 +1427,12 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@tabby_ai/hijri-converter@1.0.5': resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} engines: {node: '>=16.0.0'} @@ -1644,6 +1664,33 @@ packages: '@types/chrome@0.1.40': resolution: {integrity: sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1687,6 +1734,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} @@ -2320,6 +2370,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -2363,6 +2457,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -2571,6 +2668,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} @@ -3152,6 +3252,12 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3197,6 +3303,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + intl-messageformat@11.1.2: resolution: {integrity: sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==} @@ -4533,6 +4643,18 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react@19.2.5: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} @@ -4572,6 +4694,22 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + recharts@3.8.0: + resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -5322,6 +5460,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-node@6.0.0: resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6399,6 +6540,18 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.5 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1) + '@rolldown/binding-android-arm64@1.0.0-rc.15': optional: true @@ -6642,6 +6795,10 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@tabby_ai/hijri-converter@1.0.5': {} '@tailwindcss/node@4.2.2': @@ -6870,6 +7027,30 @@ snapshots: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.8': {} '@types/filesystem@0.0.36': @@ -6907,6 +7088,8 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/validate-npm-package-name@4.0.2': {} '@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)': @@ -7603,6 +7786,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-uri-to-buffer@4.0.1: {} data-view-buffer@1.0.2: @@ -7637,6 +7858,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} dedent@1.7.2: {} @@ -7885,6 +8108,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.45.1: {} + es6-error@4.1.1: {} esbuild@0.27.7: @@ -8548,6 +8773,10 @@ snapshots: immediate@3.0.6: {} + immer@10.2.0: {} + + immer@11.1.4: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -8584,6 +8813,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@2.0.3: {} + intl-messageformat@11.1.2: dependencies: '@formatjs/ecma402-abstract': 3.1.1 @@ -9742,6 +9973,15 @@ snapshots: react-is@16.13.1: {} + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react@19.2.5: {} read-package-up@11.0.0: @@ -9798,6 +10038,32 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + recharts@3.8.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.45.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-is: 16.13.1 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.5) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -10765,6 +11031,23 @@ snapshots: vary@1.1.2: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-node@6.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.7.1): dependencies: cac: 7.0.0 diff --git a/src/components/form/DateField.tsx b/src/components/form/DateField.tsx index 0b8fbae..4412c93 100644 --- a/src/components/form/DateField.tsx +++ b/src/components/form/DateField.tsx @@ -16,9 +16,10 @@ type DateFieldProps = Omit, "mode" | "selected" required?: boolean; disabled?: boolean; disabledDates?: ComponentProps["disabled"]; + presets?: { label: string; value: Date | Date[] | DateRange }[]; }; -export const DateField = ({ title, disabled, placeholder, mode = "single", className, disabledDates, ...props }: DateFieldProps) => { +export const DateField = ({ title, disabled, placeholder, mode = "single", className, disabledDates, presets, ...props }: DateFieldProps) => { const { name, state, handleChange, handleBlur } = useFieldContext(); const isInvalid = !state.meta.isValid && state.meta.isTouched; const id = useId(); @@ -35,9 +36,11 @@ export const DateField = ({ title, disabled, placeholder, mode = "single", class return ( - - {title} - + {title && ( + + {title} + + )} + {presets && ( +
+ {presets.map((preset) => ( + + ))} +
+ )}
{isInvalid && } diff --git a/src/components/time-entry/TimeEntryStatsCard.tsx b/src/components/time-entry/TimeEntryStatsCard.tsx new file mode 100644 index 0000000..88db361 --- /dev/null +++ b/src/components/time-entry/TimeEntryStatsCard.tsx @@ -0,0 +1,112 @@ +/* eslint-disable react/no-children-prop */ +import { useRedmineTimeEntries } from "@/api/redmine/hooks/useRedmineTimeEntries"; +import { TimeByActivityChart } from "@/components/time-entry/charts/TimeByActivityChart"; +import { TimeByProjectChart } from "@/components/time-entry/charts/TimeByProjectChart"; +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAppForm } from "@/hooks/useAppForm"; +import { Form } from "@base-ui/react"; +import { useStore } from "@tanstack/react-form"; +import { addDays, addMonths, isMonday, previousMonday, startOfDay, startOfMonth, subDays, subMonths, subWeeks } from "date-fns"; +import { useIntl } from "react-intl"; +import z from "zod"; + +export const TimeEntryStatsCard = () => { + const { formatMessage } = useIntl(); + + const today = startOfDay(new Date()); + const startOfThisWeek = isMonday(today) ? today : previousMonday(today); + const endOfThisWeek = addDays(startOfThisWeek, 6); + const startOfThisMonth = startOfMonth(today); + + const form = useAppForm({ + defaultValues: { + date: { + from: startOfThisWeek, + to: endOfThisWeek, + }, + }, + validators: { + onChange: z.object({ + date: z.object({ + from: z.date(), + to: z.date(), + }), + }), + }, + }); + const date = useStore(form.store, (state) => state.values.date); + + const entriesQuery = useRedmineTimeEntries({ + userId: "me", + from: date.from, + to: date.to, + }); + + return ( + + + {formatMessage({ id: "time.stats.title" })} + {formatMessage({ id: "time.stats.description" })} + +
+ ( + + )} + /> + +
+
+ +
+ {entriesQuery.data && entriesQuery.data.length > 0 ? ( + <> +
+ +
+
+ +
+ + ) : ( +
+ {formatMessage({ id: "time.stats.not-enough-data" })} +
+ )} +
+
+
+ ); +}; + +export const TimeEntryStatsCardSkeleton = () => { + return ( + + + + + + + + + + +
+
+
+
+ + + ); +}; diff --git a/src/components/time-entry/TimeEntryWeekOverview.tsx b/src/components/time-entry/TimeEntryWeekOverview.tsx index 761dc7b..5655e7b 100644 --- a/src/components/time-entry/TimeEntryWeekOverview.tsx +++ b/src/components/time-entry/TimeEntryWeekOverview.tsx @@ -1,5 +1,5 @@ import { Skeleton } from "@/components/ui/skeleton"; -import { addDays, format, formatISO, isFuture, isWeekend } from "date-fns"; +import { addDays, format, formatISO, isFuture, isWeekend, parseISO } from "date-fns"; import { ClockIcon } from "lucide-react"; import { useIntl } from "react-intl"; import { TTimeEntry } from "../../api/redmine/types"; @@ -11,8 +11,7 @@ import TimeEntry from "./TimeEntry"; type PropTypes = { startOfWeek: Date; - groupedTimeEntries: Map; - maxDayHours: number; + entries: TTimeEntry[]; }; export type GroupedTimeEntries = { @@ -21,10 +20,29 @@ export type GroupedTimeEntries = { hours: number; }; -export const TimeEntryWeekOverview = ({ startOfWeek, groupedTimeEntries, maxDayHours }: PropTypes) => { +export const TimeEntryWeekOverview = ({ startOfWeek, entries }: PropTypes) => { const { formatDate } = useIntl(); const formatHours = useFormatHours(); + const groupedTimeEntries = entries.reduce>((map, entry) => { + const date = entry.spent_on; + if (!map.has(date)) { + map.set(date, { + date: parseISO(date), + entries: [], + hours: 0, + }); + } + map.get(date)!.entries.push(entry); + map.get(date)!.hours += entry.hours; + return map; + }, new Map()); + + const maxDayHours = Math.max( + groupedTimeEntries.values().reduce((max, { hours }) => Math.max(max, hours), 0), + 8 + ); + const days = Array(7) .fill(startOfWeek) .map((startOfWeek: Date, i) => { diff --git a/src/components/time-entry/charts/TimeByActivityChart.tsx b/src/components/time-entry/charts/TimeByActivityChart.tsx new file mode 100644 index 0000000..2911eeb --- /dev/null +++ b/src/components/time-entry/charts/TimeByActivityChart.tsx @@ -0,0 +1,86 @@ +import { redmineTimeEntryActivitiesQuery } from "@/api/redmine/queries/timeEntryActivities"; +import { TTimeEntry } from "@/api/redmine/types"; +import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; +import { Separator } from "@/components/ui/separator"; +import useFormatHours from "@/hooks/useFormatHours"; +import { useRedmineApi } from "@/provider/RedmineApiProvider"; +import { useQuery } from "@tanstack/react-query"; +import { useIntl } from "react-intl"; +import { PolarAngleAxis, PolarGrid, PolarRadiusAxis, Radar, RadarChart } from "recharts"; + +const CHART_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"]; + +type Props = { + entries: TTimeEntry[]; +}; + +export const TimeByActivityChart = ({ entries }: Props) => { + const { formatMessage } = useIntl(); + const formatHours = useFormatHours(); + + const redmineApi = useRedmineApi(); + const { data: activities } = useQuery({ + ...redmineTimeEntryActivitiesQuery(redmineApi), + select: (data) => data.filter((activity) => activity.active !== false), + }); + + const projectActivityMap = entries.reduce>>((map, entry) => { + map[entry.project.name] ??= {}; + map[entry.project.name]![entry.activity.id] = (map[entry.project.name]![entry.activity.id] ?? 0) + entry.hours; + return map; + }, {}); + + const chartData = Object.entries(projectActivityMap) + .map(([project, activityHours]) => ({ + project, + totalHours: Object.values(activityHours).reduce((sum, h) => sum + h, 0), + ...activities?.reduce>((acc, activity) => ({ ...acc, [activity.id]: activityHours[activity.id] ?? 0 }), {}), + })) + .sort((a, b) => b.totalHours - a.totalHours); + + const chartConfig = Object.fromEntries(activities?.map((activity, i) => [activity.id, { label: activity.name, color: CHART_COLORS[i % CHART_COLORS.length] }]) ?? []) satisfies ChartConfig; + + return ( + <> + {chartData.length >= 3 ? ( + + + + + + ( + <> +
+
+ {item.name} + {formatHours(Number(value))} +
+ {index === Object.keys(chartConfig).length - 1 && ( + <> + +
+
{formatHours(item.payload.totalHours)}
+
+ + )} + + )} + /> + } + /> + {Object.entries(chartConfig).map(([activityId, { label, color }]) => ( + + ))} + + + ) : ( +
+ {formatMessage({ id: "time.stats.not-enough-data" })} +
+ )} + + ); +}; diff --git a/src/components/time-entry/charts/TimeByProjectChart.tsx b/src/components/time-entry/charts/TimeByProjectChart.tsx new file mode 100644 index 0000000..e26abb6 --- /dev/null +++ b/src/components/time-entry/charts/TimeByProjectChart.tsx @@ -0,0 +1,68 @@ +import { TTimeEntry } from "@/api/redmine/types"; +import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; +import useFormatHours from "@/hooks/useFormatHours"; +import { useIntl } from "react-intl"; +import { Label, Pie, PieChart } from "recharts"; + +const CHART_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"]; + +export const TimeByProjectChart = ({ entries }: { entries: TTimeEntry[] }) => { + const { formatMessage } = useIntl(); + const formatHours = useFormatHours(); + + const projectMap = new Map(); + for (const entry of entries) { + const name = entry.project.name; + projectMap.set(name, (projectMap.get(name) ?? 0) + entry.hours); + } + + const chartData = Array.from(projectMap.entries()) + .map(([project, hours], i) => ({ project, hours, fill: CHART_COLORS[i % CHART_COLORS.length] })) + .sort((a, b) => b.hours - a.hours); + + const chartConfig = Object.fromEntries(chartData.map(({ project }) => [project, { label: project }])) satisfies ChartConfig; + + return ( + <> + {chartData.length > 0 ? ( + + + ( + <> +
+
+ {item.name} + {formatHours(Number(value))} +
+ + )} + /> + } + /> + +