diff --git a/README.md b/README.md index 7113dcd..abae91e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![License](https://img.shields.io/github/license/CrawlerCode/redmine-time-tracking?logo=github) [![Semantic-Release](https://img.shields.io/badge/semantic--release-conventional%20commits-e10079?logo=semantic-release)](https://github.com/semantic-release/semantic-release) ![Release Version](https://img.shields.io/github/v/release/CrawlerCode/redmine-time-tracking?logo=github) -![Pre-Release Version](https://img.shields.io/github/v/release/CrawlerCode/redmine-time-tracking?include_prereleases&logo=github&label=pre-release) +![Pre-Release Version](https://img.shields.io/github/v/release/CrawlerCode/redmine-time-tracking?include_prereleases&filter=*-beta.*&logo=github&label=pre-release) ![Chrome Web Store Version](https://img.shields.io/chrome-web-store/v/ldcanhhkffokndenejhafhlkapflgcjg?logo=google-chrome&logoColor=white) ![Chrome Web Store Users](https://img.shields.io/chrome-web-store/users/ldcanhhkffokndenejhafhlkapflgcjg?logo=google-chrome&logoColor=white) diff --git a/package.json b/package.json index 0a8a3a1..06b87c1 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "react-day-picker": "^9.14.0", "react-dom": "^19.2.6", "react-intl": "^10.1.5", + "recharts": "3.8.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "zod": "^4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bcb42e..a535b4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: react-intl: specifier: ^10.1.5 version: 10.1.5(@types/react@19.2.14)(react@19.2.6) + recharts: + specifier: 3.8.0 + version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@16.13.1)(react@19.2.6)(redux@5.0.1) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1109,6 +1112,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.18': resolution: {integrity: sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1286,6 +1300,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'} @@ -1516,6 +1536,33 @@ packages: '@types/chrome@0.1.42': resolution: {integrity: sha512-tdT2roFqGecZZDjA9fUEAINb2STxSPifHMDvY6EfRjNRCjdrs/0FwKt5RCIA9MKMd1arAYZZL3nwEkp6ZLZu2w==} + '@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.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -1557,6 +1604,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==} @@ -2190,6 +2240,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'} @@ -2233,6 +2327,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -2438,6 +2535,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==} @@ -3022,6 +3122,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'} @@ -3067,6 +3173,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.2.4: resolution: {integrity: sha512-iKP6+uJXn+XcfRgYfGPE3+mqCoODV2vATrXDLo/YkYgIdelJHJPBEbc0GZThipAYPuk+8QJFiPgOfblU085ABg==} @@ -4396,6 +4506,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.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -4435,6 +4557,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'} @@ -5177,6 +5315,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} @@ -6230,6 +6371,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.6)(redux@5.0.1))(react@19.2.6)': + 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.6 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1) + '@rolldown/binding-android-arm64@1.0.0-rc.18': optional: true @@ -6398,6 +6551,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.4': @@ -6626,6 +6783,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.9': {} '@types/filesystem@0.0.36': @@ -6662,6 +6843,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.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': @@ -7358,6 +7541,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: @@ -7392,6 +7613,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + dedent@1.7.2: {} deep-extend@0.6.0: {} @@ -7637,6 +7860,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: @@ -8307,6 +8532,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 @@ -8343,6 +8572,8 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + internmap@2.0.3: {} + intl-messageformat@11.2.4: dependencies: '@formatjs/fast-memoize': 3.1.4 @@ -9491,6 +9722,15 @@ snapshots: react-is@16.13.1: {} + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react@19.2.6: {} read-package-up@11.0.0: @@ -9547,6 +9787,32 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + recharts@3.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@16.13.1)(react@19.2.6)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6) + 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.6 + react-dom: 19.2.6(react@19.2.6) + react-is: 16.13.1 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.6) + 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 @@ -10472,6 +10738,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.3)(esbuild@0.27.7)(jiti@2.7.0): 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/TimeEntryOverview.tsx b/src/components/time-entry/TimeEntryOverview.tsx new file mode 100644 index 0000000..753631c --- /dev/null +++ b/src/components/time-entry/TimeEntryOverview.tsx @@ -0,0 +1,135 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import clsx from "clsx"; +import { eachDayOfInterval, format, formatISO, isFuture, isSameWeek, isWeekend, parseISO } from "date-fns"; +import { ChevronDownIcon, ChevronUpIcon, ClockIcon } from "lucide-react"; +import { useState } from "react"; +import { useIntl } from "react-intl"; +import { TTimeEntry } from "../../api/redmine/types"; +import useFormatHours from "../../hooks/useFormatHours"; +import { roundHours } from "../../utils/date"; +import { Badge } from "../ui/badge"; +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; +import TimeEntry from "./TimeEntry"; + +type PropTypes = { + from: Date; + to: Date; + entries: TTimeEntry[]; +}; + +type GroupedTimeEntries = { + date: Date; + entries: TTimeEntry[]; + hours: number; +}; + +const INITIAL_VISIBLE = 7; + +export const TimeEntryOverview = ({ from, to, entries }: PropTypes) => { + const { formatMessage, formatDate } = useIntl(); + const formatHours = useFormatHours(); + + const groupedByDate = 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 days = eachDayOfInterval({ start: from, end: to }).map( + (date: Date) => groupedByDate.get(formatISO(date, { representation: "date" })) ?? ({ date, entries: [], hours: 0 } satisfies GroupedTimeEntries) + ); + + const maxDayHours = Math.max( + groupedByDate.values().reduce((max, { hours }) => Math.max(max, hours), 0), + 8 + ); + + const totalHours = entries.reduce((sum, entry) => sum + entry.hours, 0); + + const isMoreThanOneWeek = !isSameWeek(from, to, { weekStartsOn: 1 }); + + const [expanded, setExpanded] = useState(false); + const visibleDays = expanded ? days : days.slice(0, INITIAL_VISIBLE); + const hiddenCount = days.length - INITIAL_VISIBLE; + + return ( + + + {formatMessage({ id: "time.overview.title" })} + {formatMessage({ id: "time.overview.description" })} + + + + {formatHours(roundHours(totalHours))} + + + + + {visibleDays.map(({ date, entries, hours }) => { + const isDisabled = entries.length === 0 && (isFuture(date) || isWeekend(date)); + return ( +
+ + {isMoreThanOneWeek ? `${formatDate(date, { month: "2-digit", day: "2-digit" })} (${format(date, "EEE")})` : format(date, "EEE")} + + {isDisabled ? "–" : formatHours(roundHours(hours))} +
+ +
+
+ ); + })} + {hiddenCount > 0 && ( + + )} +
+
+ ); +}; + +export const TimeEntryOverviewSkeleton = () => { + return ( + + + + + + + + + + + + + + {[...Array(7).keys()].map((i) => ( +
+ + + + +
+ +
+
+ ))} +
+
+ ); +}; diff --git a/src/components/time-entry/TimeEntryRangePicker.tsx b/src/components/time-entry/TimeEntryRangePicker.tsx new file mode 100644 index 0000000..8fd63db --- /dev/null +++ b/src/components/time-entry/TimeEntryRangePicker.tsx @@ -0,0 +1,134 @@ +/* eslint-disable react/no-children-prop */ +import { useRedmineTimeEntries } from "@/api/redmine/hooks/useRedmineTimeEntries"; +import { Button } from "@/components/ui/button"; +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, + addWeeks, + differenceInDays, + isFirstDayOfMonth, + isLastDayOfMonth, + isMonday, + isSameMonth, + previousMonday, + startOfDay, + startOfMonth, + subDays, + subMonths, + subWeeks, +} from "date-fns"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { ReactNode } from "react"; +import z from "zod"; +import { TTimeEntry } from "../../api/redmine/types"; + +type ChildrenProps = { + entries: TTimeEntry[]; + from: Date; + to: Date; +}; + +type PropTypes = { + children: (props: ChildrenProps) => ReactNode; +}; + +export const TimeEntryRangePicker = ({ children }: PropTypes) => { + const today = startOfDay(new Date()); + const startOfThisWeek = isMonday(today) ? today : previousMonday(today); + const endOfThisWeek = addDays(startOfThisWeek, 6); + const startOfThisMonth = startOfMonth(today); + const endOfThisMonth = subDays(addMonths(startOfThisMonth, 1), 1); + + 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 isFullWeek = isMonday(date.from) && differenceInDays(date.to, date.from) === 6; + const isFullMonth = isFirstDayOfMonth(date.from) && isLastDayOfMonth(date.to) && isSameMonth(date.from, date.to); + const canNavigate = isFullWeek || isFullMonth; + + const goToPrev = () => { + if (isFullWeek) { + const prevWeekStart = subWeeks(date.from, 1); + form.setFieldValue("date", { from: prevWeekStart, to: addDays(prevWeekStart, 6) }); + } else if (isFullMonth) { + const prevMonthStart = startOfMonth(subMonths(date.from, 1)); + form.setFieldValue("date", { from: prevMonthStart, to: subDays(addMonths(prevMonthStart, 1), 1) }); + } + }; + + const goToNext = () => { + if (isFullWeek) { + const nextWeekStart = addWeeks(date.from, 1); + form.setFieldValue("date", { from: nextWeekStart, to: addDays(nextWeekStart, 6) }); + } else if (isFullMonth) { + const nextMonthStart = startOfMonth(addMonths(date.from, 1)); + form.setFieldValue("date", { from: nextMonthStart, to: subDays(addMonths(nextMonthStart, 1), 1) }); + } + }; + + const entriesQuery = useRedmineTimeEntries({ + userId: "me", + from: date.from, + to: date.to, + }); + + return ( + <> +
+ +
+ ( + + )} + /> + + +
+ + {children({ entries: entriesQuery.data ?? [], from: date.from, to: date.to })} + + ); +}; + +export const TimeEntryRangePickerSkeleton = () => { + return ( +
+ + + +
+ ); +}; diff --git a/src/components/time-entry/TimeEntryStatsCard.tsx b/src/components/time-entry/TimeEntryStatsCard.tsx new file mode 100644 index 0000000..ab30366 --- /dev/null +++ b/src/components/time-entry/TimeEntryStatsCard.tsx @@ -0,0 +1,62 @@ +import { TimeByActivityChart } from "@/components/time-entry/charts/TimeByActivityChart"; +import { TimeByProjectChart } from "@/components/time-entry/charts/TimeByProjectChart"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useIntl } from "react-intl"; +import { TTimeEntry } from "../../api/redmine/types"; + +type PropTypes = { + entries: TTimeEntry[]; +}; + +export const TimeEntryStatsCard = ({ entries }: PropTypes) => { + const { formatMessage } = useIntl(); + + return ( + + + {formatMessage({ id: "time.stats.title" })} + {formatMessage({ id: "time.stats.description" })} + + +
+ {entries.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 deleted file mode 100644 index 761dc7b..0000000 --- a/src/components/time-entry/TimeEntryWeekOverview.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; -import { addDays, format, formatISO, isFuture, isWeekend } from "date-fns"; -import { ClockIcon } from "lucide-react"; -import { useIntl } from "react-intl"; -import { TTimeEntry } from "../../api/redmine/types"; -import useFormatHours from "../../hooks/useFormatHours"; -import { roundHours } from "../../utils/date"; -import { Badge } from "../ui/badge"; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "../ui/card"; -import TimeEntry from "./TimeEntry"; - -type PropTypes = { - startOfWeek: Date; - groupedTimeEntries: Map; - maxDayHours: number; -}; - -export type GroupedTimeEntries = { - date: Date; - entries: TTimeEntry[]; - hours: number; -}; - -export const TimeEntryWeekOverview = ({ startOfWeek, groupedTimeEntries, maxDayHours }: PropTypes) => { - const { formatDate } = useIntl(); - const formatHours = useFormatHours(); - - const days = Array(7) - .fill(startOfWeek) - .map((startOfWeek: Date, i) => { - const date = addDays(startOfWeek, i); - return ( - groupedTimeEntries.get(formatISO(date, { representation: "date" })) ?? - ({ - date, - entries: [], - hours: 0, - } satisfies GroupedTimeEntries) - ); - }); - - const summedHours = days.reduce((sum, day) => sum + day.hours, 0); - - return ( - - - - {formatDate(days[0]!.date)} – {formatDate(days[6]!.date)} - - - - - {formatHours(roundHours(summedHours))} - - - - - {days.toReversed().map(({ date, entries, hours }) => { - if (isFuture(date)) return; - if (isWeekend(date) && entries.length === 0) return; - return ( -
- {format(date, "EEE")} - {formatHours(roundHours(hours))} -
- -
-
- ); - })} -
-
- ); -}; - -export const TimeEntryWeekOverviewSkeleton = () => { - return ( - - - - - - - - - - - {[...Array(5).keys()].map((e) => ( -
- - - - -
- -
-
- ))} -
-
- ); -}; diff --git a/src/components/time-entry/charts/TimeByActivityChart.tsx b/src/components/time-entry/charts/TimeByActivityChart.tsx new file mode 100644 index 0000000..b8223ab --- /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 PropTypes = { + entries: TTimeEntry[]; +}; + +export const TimeByActivityChart = ({ entries }: PropTypes) => { + 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..dd5c7fb --- /dev/null +++ b/src/components/time-entry/charts/TimeByProjectChart.tsx @@ -0,0 +1,72 @@ +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)"]; + +type PropTypes = { + entries: TTimeEntry[]; +}; + +export const TimeByProjectChart = ({ entries }: PropTypes) => { + 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))} +
+ + )} + /> + } + /> + +