diff --git a/package.json b/package.json index 739d2e88..df1d6924 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "immer": "^11.1.4", "react": "19.2.4", "react-dom": "19.2.4", + "recharts": "3.8.1", "tailwind-merge": "3.5.0", "zod": "4.3.6", "zustand": "^5.0.12" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b8fafe2..097def6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + recharts: + specifier: 3.8.1 + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1) tailwind-merge: specifier: 3.5.0 version: 3.5.0 @@ -64,7 +67,7 @@ importers: version: 4.3.6 zustand: specifier: ^5.0.12 - version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) + version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: '@eslint/js': specifier: 9.38.0 @@ -798,6 +801,17 @@ packages: engines: {node: '>=18'} hasBin: true + '@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/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -933,6 +947,9 @@ packages: '@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==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -1054,6 +1071,33 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@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/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1101,6 +1145,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} @@ -1465,6 +1512,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'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1474,6 +1565,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -1622,6 +1716,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} 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==} @@ -1699,6 +1796,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1958,6 +2058,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + immer@11.1.4: resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} @@ -1976,6 +2079,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -2474,6 +2581,21 @@ packages: peerDependencies: react: ^19.2.4 + react-is@19.2.4: + resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} + + 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-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -2490,6 +2612,22 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + 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==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2498,6 +2636,9 @@ packages: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -2694,6 +2835,9 @@ packages: tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-typed-emitter@2.1.0: resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} @@ -2789,6 +2933,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utf8-byte-length@1.0.5: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} @@ -2799,6 +2948,9 @@ packages: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3499,6 +3651,18 @@ snapshots: dependencies: playwright: 1.58.2 + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + 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.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.59.0': @@ -3580,6 +3744,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -3693,6 +3859,30 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@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/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -3745,6 +3935,8 @@ snapshots: dependencies: '@types/node': 25.5.0 + '@types/use-sync-external-store@0.0.6': {} + '@types/verror@1.10.11': optional: true @@ -4193,10 +4385,50 @@ 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: {} + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -4404,6 +4636,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.45.1: {} + es6-error@4.1.1: optional: true @@ -4556,6 +4790,8 @@ snapshots: esutils@2.0.3: {} + eventemitter3@5.0.4: {} + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -4846,6 +5082,8 @@ snapshots: ignore@7.0.5: {} + immer@10.2.0: {} + immer@11.1.4: {} import-fresh@3.3.1: @@ -4862,6 +5100,8 @@ snapshots: inherits@2.0.4: {} + internmap@2.0.3: {} + ip-address@10.1.0: {} is-extglob@2.1.1: {} @@ -5303,6 +5543,17 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-is@19.2.4: {} + + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-refresh@0.18.0: {} react@19.2.4: {} @@ -5319,12 +5570,40 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + 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.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + 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: {} + require-directory@2.1.1: {} resedit@1.7.2: dependencies: pe-library: 0.4.1 + reselect@5.1.1: {} + resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} @@ -5541,6 +5820,8 @@ snapshots: dependencies: semver: 5.7.2 + tiny-invariant@1.3.3: {} + tiny-typed-emitter@2.1.0: {} tinybench@2.9.0: {} @@ -5622,6 +5903,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + utf8-byte-length@1.0.5: {} util-deprecate@1.0.2: {} @@ -5633,6 +5918,23 @@ snapshots: extsprintf: 1.4.1 optional: true + 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@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: esbuild: 0.27.4 @@ -5742,8 +6044,9 @@ snapshots: zod@4.3.6: {} - zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4): + zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.14 immer: 11.1.4 react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) diff --git a/src/renderer/components/dashboard/charts/ChartCard.tsx b/src/renderer/components/dashboard/charts/ChartCard.tsx new file mode 100644 index 00000000..e99f5166 --- /dev/null +++ b/src/renderer/components/dashboard/charts/ChartCard.tsx @@ -0,0 +1,54 @@ +import { ChartLine } from "@phosphor-icons/react"; + +interface ChartCardProps { + title: string; + period?: string; + isEmpty?: boolean; + emptyHeading?: string; + emptyBody?: string; + ariaLabel?: string; + children: React.ReactNode; +} + +export function ChartCard({ + title, + period = "14d", + isEmpty = false, + emptyHeading = "No activity yet", + emptyBody = "Charts will appear once your company starts running tasks.", + ariaLabel, + children, +}: ChartCardProps) { + return ( +
+
+ + {title} + + + {period} + +
+ {isEmpty ? ( +
+ + + {emptyHeading} + + + {emptyBody} + +
+ ) : ( +
{children}
+ )} +
+ ); +} diff --git a/src/renderer/components/dashboard/charts/ChartTooltip.tsx b/src/renderer/components/dashboard/charts/ChartTooltip.tsx new file mode 100644 index 00000000..0a0a538c --- /dev/null +++ b/src/renderer/components/dashboard/charts/ChartTooltip.tsx @@ -0,0 +1,57 @@ +import type { TooltipContentProps } from "recharts"; + +interface ChartTooltipExtraProps { + valuePrefix?: string; + valueSuffix?: string; +} + +type Props = Partial> & ChartTooltipExtraProps; + +export function ChartTooltip({ + active, + payload, + label, + valuePrefix = "", + valueSuffix = "", +}: Props) { + if (!active || !payload?.length) return null; + + const dateStr = + typeof label === "string" + ? new Date(label + "T00:00:00").toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : String(label); + + return ( +
+
{dateStr}
+ {payload.map((entry, i) => ( +
+ + + {valuePrefix} + {typeof entry.value === "number" + ? entry.value.toLocaleString() + : entry.value} + {valueSuffix} + +
+ ))} +
+ ); +} diff --git a/src/renderer/components/dashboard/charts/chart-utils.ts b/src/renderer/components/dashboard/charts/chart-utils.ts new file mode 100644 index 00000000..6c9f944e --- /dev/null +++ b/src/renderer/components/dashboard/charts/chart-utils.ts @@ -0,0 +1,142 @@ +import type { RunRecord, CostRecord, AgentRecord } from "@shared/types"; + +export interface DailyRunData { + date: string; // "YYYY-MM-DD" + total: number; + succeeded: number; + failed: number; +} + +export interface DailyCostData { + date: string; // "YYYY-MM-DD" + total: number; // USD amount (NOT cents) +} + +export interface AgentWorkloadData { + name: string; // agent name, truncated to 10 chars + runs: number; +} + +/** + * Aggregate runs into daily buckets for chart consumption. + * Returns `days` entries sorted ascending by date (oldest first). + * Days with no runs have {total:0, succeeded:0, failed:0}. + */ +export function aggregateRunsByDay(runs: RunRecord[], days: number): DailyRunData[] { + const now = new Date(); + const buckets = new Map(); + + // Initialize all day buckets with zero values + for (let i = 0; i < days; i++) { + const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000); + const key = date.toISOString().slice(0, 10); + buckets.set(key, { total: 0, succeeded: 0, failed: 0 }); + } + + // Fill buckets from actual data + for (const run of runs) { + const day = run.createdAt.slice(0, 10); + const bucket = buckets.get(day); + if (!bucket) continue; + bucket.total++; + if (run.status === "succeeded") bucket.succeeded++; + if (run.status === "failed" || run.status === "timed_out" || run.status === "interrupted") { + bucket.failed++; + } + } + + // Return sorted ascending by date + return Array.from(buckets.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, data]) => ({ date, ...data })); +} + +/** + * Aggregate costs into daily buckets for chart consumption. + * Returns `days` entries sorted ascending by date. + * Days with no costs have {total:0}. Null amountUsd is treated as 0. + */ +export function aggregateCostsByDay(costs: CostRecord[], days: number): DailyCostData[] { + const now = new Date(); + const buckets = new Map(); + + // Initialize all day buckets with zero + for (let i = 0; i < days; i++) { + const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000); + const key = date.toISOString().slice(0, 10); + buckets.set(key, 0); + } + + // Sum costs per day + for (const cost of costs) { + const day = cost.createdAt.slice(0, 10); + const current = buckets.get(day); + if (current === undefined) continue; + buckets.set(day, current + (cost.amountUsd ?? 0)); + } + + // Return sorted ascending by date + return Array.from(buckets.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, total]) => ({ date, total })); +} + +/** + * Group runs by agent and return workload data sorted by run count descending. + * If more than maxAgents, keep top maxAgents and aggregate rest into "Others". + * Agent names longer than 10 chars are truncated with ellipsis. + */ +export function aggregateAgentWorkload( + runs: RunRecord[], + agents: AgentRecord[], + maxAgents: number = 8, +): AgentWorkloadData[] { + // Build name lookup from agents array + const nameMap = new Map(); + for (const agent of agents) { + nameMap.set(agent.id, agent.name); + } + + // Count runs per agent + const counts = new Map(); + for (const run of runs) { + counts.set(run.agentId, (counts.get(run.agentId) ?? 0) + 1); + } + + // Sort by count descending + const sorted = Array.from(counts.entries()) + .sort(([, a], [, b]) => b - a); + + const truncateName = (name: string): string => + name.length > 10 ? name.slice(0, 10) + "..." : name; + + // If within limit, return all + if (sorted.length <= maxAgents) { + return sorted.map(([agentId, runCount]) => ({ + name: truncateName(nameMap.get(agentId) ?? agentId), + runs: runCount, + })); + } + + // Split into top agents and "Others" + const top = sorted.slice(0, maxAgents); + const rest = sorted.slice(maxAgents); + const othersRuns = rest.reduce((sum, [, count]) => sum + count, 0); + + const result: AgentWorkloadData[] = top.map(([agentId, runCount]) => ({ + name: truncateName(nameMap.get(agentId) ?? agentId), + runs: runCount, + })); + + result.push({ name: "Others", runs: othersRuns }); + return result; +} + +/** + * Format a YYYY-MM-DD date string as "MMM dd" (e.g., "Mar 15"). + * Uses T00:00:00 suffix to avoid timezone shift. + */ +export function formatShortDate(dateStr: string): string { + const date = new Date(dateStr + "T00:00:00"); + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} diff --git a/tests/unit/dashboard-charts.test.ts b/tests/unit/dashboard-charts.test.ts new file mode 100644 index 00000000..95fcbb05 --- /dev/null +++ b/tests/unit/dashboard-charts.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { RunRecord, CostRecord, AgentRecord } from "../../src/shared/types"; +import { + aggregateRunsByDay, + aggregateCostsByDay, + aggregateAgentWorkload, + formatShortDate, +} from "../../src/renderer/components/dashboard/charts/chart-utils"; +import type { + DailyRunData, + DailyCostData, + AgentWorkloadData, +} from "../../src/renderer/components/dashboard/charts/chart-utils"; + +// Helper to create a minimal RunRecord for testing +function makeRun(overrides: Partial = {}): RunRecord { + return { + id: "run-1", + companyId: "co-1", + taskId: "task-1", + agentId: "agent-1", + workspaceId: null, + connectorId: "claude_local", + status: "succeeded", + summary: "", + errorMessage: null, + exitCode: null, + signal: null, + model: null, + sessionDisplayId: null, + costUsd: null, + unattributedCost: false, + startedAt: null, + finishedAt: null, + logPath: null, + createdAt: "2026-03-20T10:00:00Z", + updatedAt: "2026-03-20T10:00:00Z", + ...overrides, + }; +} + +// Helper to create a minimal CostRecord for testing +function makeCost(overrides: Partial = {}): CostRecord { + return { + id: "cost-1", + companyId: "co-1", + runId: "run-1", + taskId: null, + agentId: null, + connectorId: "claude_local", + amountUsd: 0.5, + unattributed: false, + createdAt: "2026-03-20T10:00:00Z", + ...overrides, + }; +} + +// Helper to create a minimal AgentRecord for testing +function makeAgent(overrides: Partial = {}): AgentRecord { + return { + id: "agent-1", + companyId: "co-1", + name: "Alice", + role: "engineer", + title: "Software Engineer", + department: "engineering", + status: "active", + reportsTo: null, + connectorId: "claude_local", + workspaceId: null, + model: null, + capabilities: "[]", + budgetMonthlyUsd: 100, + spentMonthlyUsd: 0, + metadataJson: "{}", + heartbeatEnabled: false, + heartbeatIntervalSec: 300, + lastHeartbeatAt: null, + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + ...overrides, + }; +} + +// Fix "today" to a known date so bucket generation is deterministic +const FIXED_NOW = new Date("2026-03-27T12:00:00Z"); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("aggregateRunsByDay", () => { + it("returns 14 entries all zeroed when given empty runs array", () => { + const result = aggregateRunsByDay([], 14); + expect(result).toHaveLength(14); + for (const entry of result) { + expect(entry.total).toBe(0); + expect(entry.succeeded).toBe(0); + expect(entry.failed).toBe(0); + } + }); + + it("counts 3 runs on same day: 2 succeeded, 1 failed", () => { + const runs = [ + makeRun({ id: "r1", status: "succeeded", createdAt: "2026-03-25T08:00:00Z" }), + makeRun({ id: "r2", status: "succeeded", createdAt: "2026-03-25T15:00:00Z" }), + makeRun({ id: "r3", status: "failed", createdAt: "2026-03-25T20:00:00Z" }), + ]; + const result = aggregateRunsByDay(runs, 14); + const day25 = result.find((d) => d.date === "2026-03-25"); + expect(day25).toBeDefined(); + expect(day25!.total).toBe(3); + expect(day25!.succeeded).toBe(2); + expect(day25!.failed).toBe(1); + }); + + it("returns dates sorted ascending (oldest first)", () => { + const result = aggregateRunsByDay([], 14); + for (let i = 1; i < result.length; i++) { + expect(result[i].date > result[i - 1].date).toBe(true); + } + }); + + it("excludes runs outside the 14-day window", () => { + const oldRun = makeRun({ + id: "old", + status: "succeeded", + createdAt: "2026-03-01T10:00:00Z", // well before 14 days ago + }); + const result = aggregateRunsByDay([oldRun], 14); + const total = result.reduce((sum, d) => sum + d.total, 0); + expect(total).toBe(0); + }); + + it("counts timed_out and interrupted as failed", () => { + const runs = [ + makeRun({ id: "r1", status: "timed_out", createdAt: "2026-03-25T10:00:00Z" }), + makeRun({ id: "r2", status: "interrupted", createdAt: "2026-03-25T11:00:00Z" }), + ]; + const result = aggregateRunsByDay(runs, 14); + const day25 = result.find((d) => d.date === "2026-03-25"); + expect(day25!.total).toBe(2); + expect(day25!.failed).toBe(2); + expect(day25!.succeeded).toBe(0); + }); +}); + +describe("aggregateCostsByDay", () => { + it("returns 14 entries all zeroed when given empty costs array", () => { + const result = aggregateCostsByDay([], 14); + expect(result).toHaveLength(14); + for (const entry of result) { + expect(entry.total).toBe(0); + } + }); + + it("sums costs per day correctly", () => { + const costs = [ + makeCost({ id: "c1", amountUsd: 1.5, createdAt: "2026-03-25T08:00:00Z" }), + makeCost({ id: "c2", amountUsd: 2.25, createdAt: "2026-03-25T12:00:00Z" }), + makeCost({ id: "c3", amountUsd: 0.75, createdAt: "2026-03-26T10:00:00Z" }), + ]; + const result = aggregateCostsByDay(costs, 14); + const day25 = result.find((d) => d.date === "2026-03-25"); + const day26 = result.find((d) => d.date === "2026-03-26"); + expect(day25!.total).toBeCloseTo(3.75); + expect(day26!.total).toBeCloseTo(0.75); + }); + + it("handles null amountUsd as 0", () => { + const costs = [ + makeCost({ id: "c1", amountUsd: null, createdAt: "2026-03-25T10:00:00Z" }), + makeCost({ id: "c2", amountUsd: 1.0, createdAt: "2026-03-25T11:00:00Z" }), + ]; + const result = aggregateCostsByDay(costs, 14); + const day25 = result.find((d) => d.date === "2026-03-25"); + expect(day25!.total).toBeCloseTo(1.0); + }); +}); + +describe("aggregateAgentWorkload", () => { + it("returns entries sorted by run count descending for 3 agents", () => { + const agents = [ + makeAgent({ id: "a1", name: "Alice" }), + makeAgent({ id: "a2", name: "Bob" }), + makeAgent({ id: "a3", name: "Charlie" }), + ]; + const runs = [ + makeRun({ id: "r1", agentId: "a1" }), + makeRun({ id: "r2", agentId: "a1" }), + makeRun({ id: "r3", agentId: "a1" }), + makeRun({ id: "r4", agentId: "a2" }), + makeRun({ id: "r5", agentId: "a2" }), + makeRun({ id: "r6", agentId: "a3" }), + ]; + const result = aggregateAgentWorkload(runs, agents); + expect(result).toHaveLength(3); + expect(result[0].name).toBe("Alice"); + expect(result[0].runs).toBe(3); + expect(result[1].name).toBe("Bob"); + expect(result[1].runs).toBe(2); + expect(result[2].name).toBe("Charlie"); + expect(result[2].runs).toBe(1); + }); + + it("aggregates into top 8 + Others when 10 agents exist", () => { + const agents: AgentRecord[] = []; + const runs: RunRecord[] = []; + for (let i = 1; i <= 10; i++) { + agents.push(makeAgent({ id: `a${i}`, name: `Agent${i}` })); + // Each agent gets (11 - i) runs so agent1 has most + for (let j = 0; j < 11 - i; j++) { + runs.push(makeRun({ id: `r${i}-${j}`, agentId: `a${i}` })); + } + } + const result = aggregateAgentWorkload(runs, agents); + expect(result).toHaveLength(9); // 8 top + Others + expect(result[result.length - 1].name).toBe("Others"); + // Others should aggregate agent9 (2 runs) + agent10 (1 run) = 3 runs + expect(result[result.length - 1].runs).toBe(3); + }); + + it("resolves agentId to agent name", () => { + const agents = [makeAgent({ id: "a1", name: "DeepResearcher" })]; + const runs = [makeRun({ id: "r1", agentId: "a1" })]; + const result = aggregateAgentWorkload(runs, agents); + expect(result[0].name).toBe("DeepResear..."); + }); + + it("truncates agent names longer than 10 characters with ellipsis", () => { + const agents = [makeAgent({ id: "a1", name: "VeryLongAgentName" })]; + const runs = [makeRun({ id: "r1", agentId: "a1" })]; + const result = aggregateAgentWorkload(runs, agents); + expect(result[0].name).toBe("VeryLongAg..."); + expect(result[0].name.length).toBe(13); // 10 chars + "..." + }); + + it("does not truncate short names", () => { + const agents = [makeAgent({ id: "a1", name: "ShortName" })]; + const runs = [makeRun({ id: "r1", agentId: "a1" })]; + const result = aggregateAgentWorkload(runs, agents); + expect(result[0].name).toBe("ShortName"); + }); +}); + +describe("formatShortDate", () => { + it('formats "2026-03-15" as "Mar 15"', () => { + const result = formatShortDate("2026-03-15"); + expect(result).toBe("Mar 15"); + }); + + it('formats "2026-01-01" as "Jan 1"', () => { + const result = formatShortDate("2026-01-01"); + expect(result).toBe("Jan 1"); + }); + + it('formats "2026-12-25" as "Dec 25"', () => { + const result = formatShortDate("2026-12-25"); + expect(result).toBe("Dec 25"); + }); +});