diff --git a/backend/dashboard/package.json b/backend/dashboard/package.json index dca48a5a64..3cef6c51ae 100644 --- a/backend/dashboard/package.json +++ b/backend/dashboard/package.json @@ -16,6 +16,7 @@ "radix-ui": "^1.6.7", "react": "19.2.8", "react-dom": "19.2.8", + "recharts": "3.8.0", "shadcn": "^4.16.1", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0" diff --git a/backend/dashboard/pnpm-lock.yaml b/backend/dashboard/pnpm-lock.yaml index 7136f9aa45..07551ce2a8 100644 --- a/backend/dashboard/pnpm-lock.yaml +++ b/backend/dashboard/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) + recharts: + specifier: 3.8.0 + version: 3.8.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1) shadcn: specifier: ^4.16.1 version: 4.16.1(typescript@7.0.2) @@ -974,6 +977,17 @@ packages: '@radix-ui/rect@1.1.3': resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + 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.2.3': resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1074,6 +1088,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==} + '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -1171,6 +1191,33 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@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/node@26.1.2': resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} @@ -1182,6 +1229,9 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@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==} @@ -1499,6 +1549,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'} + debounce-fn@4.0.0: resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} engines: {node: '>=10'} @@ -1512,6 +1606,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: @@ -1626,6 +1723,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} @@ -1788,6 +1888,12 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.15: + resolution: {integrity: sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1795,6 +1901,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.4.0: resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} engines: {node: '>= 12'} @@ -2351,6 +2461,21 @@ packages: peerDependencies: react: ^19.2.8 + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/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-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -2389,10 +2514,29 @@ packages: resolution: {integrity: sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==} 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==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2628,6 +2772,11 @@ packages: '@types/react': optional: true + 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 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2639,6 +2788,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@8.2.0: resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3768,6 +3920,18 @@ snapshots: '@radix-ui/rect@1.1.3': {} + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.15 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.8 + react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + '@rolldown/binding-android-arm64@1.2.3': optional: true @@ -3816,6 +3980,10 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3890,6 +4058,30 @@ snapshots: minimatch: 10.2.6 path-browserify: 1.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/node@26.1.2': dependencies: undici-types: 8.3.0 @@ -3902,6 +4094,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/use-sync-external-store@0.0.6': {} + '@types/validate-npm-package-name@4.0.2': {} '@typescript/typescript-aix-ppc64@7.0.2': @@ -4130,6 +4324,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: {} + debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 @@ -4138,6 +4370,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + dedent@1.7.2: {} deepmerge@4.3.1: {} @@ -4215,6 +4449,8 @@ snapshots: etag@1.8.1: {} + eventemitter3@5.0.4: {} + eventsource-parser@3.1.0: {} eventsource@3.0.7: @@ -4416,6 +4652,10 @@ snapshots: ignore@5.3.2: {} + immer@10.2.0: {} + + immer@11.1.15: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -4423,6 +4663,8 @@ snapshots: inherits@2.0.4: {} + internmap@2.0.3: {} + ip-address@10.4.0: {} ipaddr.js@1.9.1: {} @@ -4894,6 +5136,17 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 + react-is@19.2.8: {} + + react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + redux: 5.0.1 + react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 @@ -4931,8 +5184,36 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + recharts@3.8.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.50.0 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-is: 19.2.8 + react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.8) + 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-from-string@2.0.2: {} + reselect@5.1.1: {} + resolve-from@4.0.0: {} restore-cursor@5.1.0: @@ -5223,12 +5504,33 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + util-deprecate@1.0.2: {} validate-npm-package-name@7.0.2: {} 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@8.2.0(@types/node@26.1.2)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 diff --git a/backend/dashboard/pretix_analytics.py b/backend/dashboard/pretix_analytics.py new file mode 100644 index 0000000000..e30022252a --- /dev/null +++ b/backend/dashboard/pretix_analytics.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import logging +from collections import Counter +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal, InvalidOperation +from typing import Any + +import requests +from django.conf import settings +from django.core.cache import cache +from django.utils import timezone +from django.utils.dateparse import parse_datetime + +from conferences.models import Conference +from pretix import get_items, get_orders + +logger = logging.getLogger(__name__) + +CACHE_VERSION = 1 +PAID_ORDER_STATUS = "p" +CANCELED_ORDER_STATUS = "c" + + +def _display_money(value: Decimal) -> str: + return f"€{value:,.0f}" + + +def _item_name(item: dict[str, Any]) -> str: + name = item.get("name") + if isinstance(name, str): + return name + if isinstance(name, dict): + return str(name.get("en") or name.get("it") or next(iter(name.values()), "")) + return "Ticket" + + +def _week_values(today: date) -> list[dict[str, int | str]]: + current_week = today - timedelta(days=today.weekday()) + return [ + { + "label": f"{(current_week - timedelta(weeks=offset)).strftime('%b')} " + f"{(current_week - timedelta(weeks=offset)).day}", + "value": 0, + } + for offset in range(7, -1, -1) + ] + + +def unavailable_ticket_analytics( + conference: Conference, + *, + status: str, +) -> dict[str, Any]: + year = conference.start.year if conference.start else timezone.localdate().year + return { + "id": "ticket-sales", + "title": "Ticket sales", + "period": "Last 8 weeks", + "summary": "Pretix data unavailable", + "total": None, + "values": _week_values(timezone.localdate(timezone=conference.timezone)), + "comparisonValues": [], + "currentLabel": f"{year} cumulative sold", + "comparisonLabel": f"{year - 1}, same period", + "annotations": [], + "comparisonAnnotations": [], + "breakdown": [], + "comparisonBreakdown": [], + "products": [], + "details": [ + {"label": "Data source", "value": "Pretix"}, + {"label": "Status", "value": status}, + ], + "allocatedTotal": None, + } + + +def _order_datetime(order: dict[str, Any]) -> datetime | None: + value = parse_datetime(str(order.get("datetime") or "")) + if value is None: + return None + if timezone.is_naive(value): + return timezone.make_aware(value, timezone=UTC) + return value + + +def _build_ticket_analytics(conference: Conference) -> dict[str, Any]: + items = get_items(conference) + paid_orders = list( + get_orders( + conference, + {"status": PAID_ORDER_STATUS, "testmode": "false"}, + ) + ) + canceled_orders = list( + get_orders( + conference, + {"status": CANCELED_ORDER_STATUS, "testmode": "false"}, + ) + ) + + today = timezone.localdate(timezone=conference.timezone) + current_week = today - timedelta(days=today.weekday()) + first_week = current_week - timedelta(weeks=7) + values = _week_values(today) + counts: Counter[str] = Counter() + gross = Decimal(0) + last_seven_days = 0 + + for order in paid_orders: + ordered_at = _order_datetime(order) + local_order_date = ( + timezone.localtime(ordered_at, timezone=conference.timezone).date() + if ordered_at + else None + ) + admission_positions = [] + + for position in order.get("positions", []): + item_id = str(position.get("item")) + item = items.get(item_id) + if not item or not item.get("admission") or position.get("canceled", False): + continue + + admission_positions.append(position) + counts[item_id] += 1 + gross += Decimal(str(position.get("price") or "0")) + + if local_order_date is None or not admission_positions: + continue + + week_index = (local_order_date - first_week).days // 7 + if 0 <= week_index < len(values): + values[week_index]["value"] += len(admission_positions) + if local_order_date >= today - timedelta(days=6): + last_seven_days += len(admission_positions) + + total = sum(counts.values()) + breakdown = [] + products = [] + for item_id, count in sorted( + counts.items(), + key=lambda row: (-row[1], _item_name(items[row[0]]).casefold()), + ): + item = items[item_id] + name = _item_name(item) + price = Decimal(str(item.get("default_price") or "0")) + breakdown.append( + { + "id": item_id, + "label": f"{name} · {_display_money(price)}", + "count": count, + "share": round(count / total * 100) if total else 0, + } + ) + products.append( + { + "id": item_id, + "name": name, + "price": _display_money(price), + "tier": str(item.get("internal_name") or "Pretix"), + } + ) + + completed_refunds = sum( + refund.get("state") == "done" + for order in [*paid_orders, *canceled_orders] + for refund in order.get("refunds", []) + ) + year = conference.start.year if conference.start else today.year + + return { + "id": "ticket-sales", + "title": "Ticket sales", + "period": "Last 8 weeks", + "summary": f"{_display_money(gross)} gross", + "total": total, + "values": values, + "comparisonValues": [], + "currentLabel": f"{year} cumulative sold", + "comparisonLabel": f"{year - 1}, same period", + "annotations": [], + "comparisonAnnotations": [], + "breakdown": breakdown, + "comparisonBreakdown": [], + "products": products, + "details": [ + {"label": "Last 7 days", "value": f"+{last_seven_days:,}"}, + {"label": "Gross", "value": _display_money(gross)}, + {"label": "Refunds processed", "value": f"{completed_refunds:,}"}, + ], + "allocatedTotal": None, + } + + +def load_pretix_ticket_analytics(conference: Conference) -> dict[str, Any]: + if not settings.PRETIX_API or not settings.PRETIX_API_TOKEN: + return unavailable_ticket_analytics(conference, status="Not configured") + if not conference.pretix_organizer_id or not conference.pretix_event_id: + return unavailable_ticket_analytics( + conference, + status="Conference not linked", + ) + + cache_key = ( + f"dashboard:pretix:v{CACHE_VERSION}:" + f"{conference.pretix_organizer_id}:{conference.pretix_event_id}" + ) + cached = cache.get(cache_key) + if cached is not None: + return cached + + try: + analytics = _build_ticket_analytics(conference) + except ( + InvalidOperation, + KeyError, + TypeError, + ValueError, + requests.RequestException, + ): + logger.exception( + "Unable to load dashboard ticket analytics from Pretix", + extra={"conference_code": conference.code}, + ) + analytics = unavailable_ticket_analytics( + conference, + status="Temporarily unavailable", + ) + cache.set( + cache_key, + analytics, + timeout=settings.DASHBOARD_PRETIX_ERROR_CACHE_TIMEOUT, + ) + return analytics + + cache.set( + cache_key, + analytics, + timeout=settings.DASHBOARD_PRETIX_CACHE_TIMEOUT, + ) + return analytics diff --git a/backend/dashboard/src/app.tsx b/backend/dashboard/src/app.tsx index aa0489ec23..6d23c278be 100644 --- a/backend/dashboard/src/app.tsx +++ b/backend/dashboard/src/app.tsx @@ -8,6 +8,7 @@ import "./styles.css"; const pages = { Dashboard, Login }; createInertiaApp({ + title: (title) => (title ? `${title} · Dashboard` : "Dashboard"), resolve: (name) => { const page = pages[name as keyof typeof pages]; diff --git a/backend/dashboard/src/components/app-sidebar.tsx b/backend/dashboard/src/components/app-sidebar.tsx index b03162ad73..2f3e4389fb 100644 --- a/backend/dashboard/src/components/app-sidebar.tsx +++ b/backend/dashboard/src/components/app-sidebar.tsx @@ -6,7 +6,6 @@ import { ConferenceSwitcher, } from "@/components/conference-switcher"; import { NavUser, type NavUserData } from "@/components/nav-user"; - import { Sidebar, SidebarContent, @@ -18,24 +17,28 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, - SidebarRail, } from "@/components/ui/sidebar"; +import { dashboardUrl as buildDashboardUrl } from "@/dashboard-url"; export function AppSidebar({ + comparisonCodes, conferences, selectedConference, user, }: { + comparisonCodes: string[]; conferences: ConferenceData[]; selectedConference: ConferenceData | null; user: NavUserData; }) { const dashboardUrl = selectedConference - ? `/dashboard/${encodeURIComponent(selectedConference.code)}` + ? buildDashboardUrl(selectedConference.code, { + comparisonCodes, + }) : "/dashboard"; return ( - + - - ); } diff --git a/backend/dashboard/src/components/conference-analytics.tsx b/backend/dashboard/src/components/conference-analytics.tsx new file mode 100644 index 0000000000..18bfe78cd8 --- /dev/null +++ b/backend/dashboard/src/components/conference-analytics.tsx @@ -0,0 +1,739 @@ +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Line, + ReferenceLine, + XAxis, + YAxis, +} from "recharts"; + +export type AnalyticsWeekData = { + label: string; + value: number; +}; + +export type AnalyticsBreakdownData = { + count: number; + id: string; + label: string; + share: number; +}; + +export type AnalyticsDetailData = { + label: string; + value: string; +}; + +export type AnalyticsProductData = { + id: string; + name: string; + price: string; + tier: string; +}; + +export type AnalyticsAnnotationData = { + date: string; + id: string; + label: string; + x: number | string; +}; + +export type AnalyticsChartData = { + allocatedTotal: number | null; + annotations: AnalyticsAnnotationData[]; + breakdown: AnalyticsBreakdownData[]; + comparisonAnnotations: AnalyticsAnnotationData[]; + comparisonBreakdown: AnalyticsBreakdownData[]; + comparisonLabel: string; + comparisonValues: AnalyticsWeekData[]; + currentLabel: string; + details: AnalyticsDetailData[]; + id: string; + period: string; + products: AnalyticsProductData[]; + summary: string; + title: string; + total: number | null; + values: AnalyticsWeekData[]; +}; + +export type AnalyticsConferenceData = { + analytics: AnalyticsChartData[]; + code: string; + name: string; + year: number | null; +}; + +type AnalyticsChartSeries = { + chart: AnalyticsChartData; + conference: AnalyticsConferenceData; +}; + +function cumulativeValues(values: AnalyticsWeekData[]) { + let total = 0; + + return values.map((week) => { + total += week.value; + return { ...week, value: total }; + }); +} + +function CumulativeLineChart({ series }: { series: AnalyticsChartSeries[] }) { + const primary = series[0]; + const hasExplicitComparisons = series.length > 1; + const implicitComparisonValues = cumulativeValues( + primary.chart.comparisonValues, + ); + const displayedSeries = hasExplicitComparisons + ? series.map(({ chart, conference }, index) => ({ + annotationLabel: String(conference.year ?? conference.name), + annotations: chart.annotations, + color: `var(--chart-${(index % 5) + 1})`, + dashed: false, + key: `conference${index}`, + label: conference.name, + values: cumulativeValues(chart.values), + })) + : [ + { + annotationLabel: "", + annotations: primary.chart.annotations, + color: "var(--chart-1)", + dashed: false, + key: "conference0", + label: primary.chart.currentLabel, + values: cumulativeValues(primary.chart.values), + }, + ...(implicitComparisonValues.length + ? [ + { + annotationLabel: "", + annotations: primary.chart.comparisonAnnotations, + color: "var(--chart-2)", + dashed: true, + key: "conference1", + label: primary.chart.comparisonLabel, + values: implicitComparisonValues, + }, + ] + : []), + ]; + const values = Array.from( + { + length: Math.max( + ...displayedSeries.map((chartSeries) => chartSeries.values.length), + ), + }, + (_, index) => { + const value: Record = { + label: + displayedSeries[0].values[index]?.label ?? + displayedSeries.find((chartSeries) => chartSeries.values[index]) + ?.values[index]?.label ?? + "", + position: index, + }; + + for (const chartSeries of displayedSeries) { + value[chartSeries.key] = chartSeries.values[index]?.value; + } + + return value; + }, + ); + const annotationPosition = (x: number | string) => { + if (typeof x === "number") { + return x; + } + + const position = values.findIndex((value) => value.label === x); + return position >= 0 ? position : 0; + }; + const annotationRows = displayedSeries.filter( + (chartSeries) => chartSeries.annotations.length, + ).length; + const chartConfig = Object.fromEntries( + displayedSeries.map((chartSeries) => [ + chartSeries.key, + { + color: chartSeries.color, + label: chartSeries.label, + }, + ]), + ) satisfies ChartConfig; + const unavailable = series.every(({ chart }) => chart.total === null); + + return ( +
+ {unavailable ? null : ( +
    + {displayedSeries.map((chartSeries) => ( +
  • +
  • + ))} + {annotationRows ? ( +
  • +
  • + ) : null} +
+ )} +
+
+ + + + + String(values[Math.round(position)]?.label ?? "") + } + tickLine={false} + tickMargin={10} + ticks={values.map((value) => Number(value.position))} + type="number" + /> + + {unavailable ? null : ( + <> + + payload?.[0]?.payload?.label + } + /> + } + cursor={false} + isAnimationActive={false} + /> + {displayedSeries.flatMap((chartSeries, seriesIndex) => + chartSeries.annotations.map((annotation, index) => ( + + )), + )} + {hasExplicitComparisons ? ( + displayedSeries.map((chartSeries, index) => ( + + )) + ) : ( + <> + {displayedSeries[1] ? ( + + ) : null} + + + )} + + )} + + + {unavailable ? ( +

+ Connect Pretix to populate this chart +

+ ) : null} +
+
+
+ {unavailable + ? `${primary.chart.title} data is unavailable.` + : `${primary.chart.title} cumulative totals for the selected conferences.`} +
+
+ ); +} + +function trendLabel(current: number | null, comparison: number | null) { + if (current === null || comparison === null || comparison === 0) { + return null; + } + + const change = ((current - comparison) / comparison) * 100; + const formatted = new Intl.NumberFormat("en", { + maximumFractionDigits: 1, + signDisplay: "always", + }).format(change); + + return `${formatted}%`; +} + +function MetricHeading({ series }: { series: AnalyticsChartSeries[] }) { + const chart = series[0].chart; + + return ( +
+
+

{chart.title}

+

+ {chart.period} +

+
+
+

+ {chart.total?.toLocaleString() ?? "—"} +

+

+ {chart.summary} +

+
+ {series.length > 1 ? ( +
    + {series + .slice(1) + .map(({ chart: comparisonChart, conference }, index) => { + const trend = trendLabel(chart.total, comparisonChart.total); + + return ( +
  • +
  • + ); + })} +
+ ) : null} +
+ ); +} + +function BreakdownTable({ + chart, + countLabel, +}: { + chart: AnalyticsChartData; + countLabel: string; +}) { + return ( +
+ + + + + + + + + + {chart.breakdown.length ? ( + chart.breakdown.map((row) => ( + + + + + + )) + ) : ( + + + + )} + + + + + + + + +
+ Status + + {countLabel} + + Share +
+ {row.label} + + {row.count.toLocaleString()} + + {row.share}% +
+ No data yet +
+ Total received + + {chart.total?.toLocaleString() ?? 0} + + {chart.total ? "100%" : "0%"} +
+
+ ); +} + +function TicketSalesPanel({ series }: { series: AnalyticsChartSeries[] }) { + const chart = series[0].chart; + const hasExplicitComparisons = series.length > 1; + const mixSeries = hasExplicitComparisons + ? series.map(({ chart: seriesChart, conference }, index) => ({ + breakdown: seriesChart.breakdown, + color: `var(--chart-${(index % 5) + 1})`, + id: conference.code, + label: String(conference.year ?? conference.name), + })) + : [ + { + breakdown: chart.breakdown, + color: "var(--chart-1)", + id: "current", + label: chart.currentLabel.match(/\d{4}/)?.[0] ?? "Current", + }, + ...(chart.comparisonBreakdown.length + ? [ + { + breakdown: chart.comparisonBreakdown, + color: "var(--chart-2)", + id: "previous", + label: chart.comparisonLabel.match(/\d{4}/)?.[0] ?? "Previous", + }, + ] + : []), + ]; + const breakdownRows = Array.from( + new Set(mixSeries.flatMap((item) => item.breakdown.map((row) => row.id))), + ).map((id) => ({ + id, + label: + mixSeries.flatMap((item) => item.breakdown).find((row) => row.id === id) + ?.label ?? id, + })); + + return ( +
+
+ +
+ {chart.details.map((detail) => ( +
+
{detail.label}
+
+ {detail.value} +
+
+ ))} +
+
+
+ + {breakdownRows.length ? ( +
+
+

Ticket mix

+

+ {hasExplicitComparisons + ? `${mixSeries.length} conferences` + : "Current totals"} +

+
+
+ {breakdownRows.map((row) => ( +
+
{row.label}
+
+ {mixSeries.map((item) => { + const value = item.breakdown.find( + (breakdown) => breakdown.id === row.id, + ); + + return ( + + + ); + })} +
+
+ ))} +
+
+ ) : null} + {chart.products.length ? ( +
+
+

Advertised tickets

+

Public site

+
+
+ {chart.products.map((product) => ( +
+
{product.name}
+
+ + {product.price} + + {product.tier ? {product.tier} : null} +
+
+ ))} +
+
+ ) : null} +
+
+ ); +} + +function ProposalsPanel({ series }: { series: AnalyticsChartSeries[] }) { + const chart = series[0].chart; + + return ( +
+ + + +
+ ); +} + +function StatusDistribution({ chart }: { chart: AnalyticsChartData }) { + const chartData: Record = { group: "Requests" }; + const chartConfig: ChartConfig = {}; + + chart.breakdown.forEach((row, index) => { + const key = `status${index}`; + chartData[key] = row.count; + chartConfig[key] = { + color: `var(--chart-${(index % 5) + 1})`, + label: row.label, + }; + }); + + return ( +
+ + + + + } + cursor={false} + isAnimationActive={false} + /> + {chart.breakdown.map((row, index) => ( + + ))} + + +
+ Grant requests grouped by status. +
+
+ ); +} + +function formatCurrency(value: number) { + return new Intl.NumberFormat("en-IE", { + currency: "EUR", + maximumFractionDigits: 0, + style: "currency", + }).format(value); +} + +function GrantsPanel({ series }: { series: AnalyticsChartSeries[] }) { + const chart = series[0].chart; + + return ( +
+ + + + +
+

Allocated

+

+ {formatCurrency(chart.allocatedTotal ?? 0)} +

+

+ Approved, awaiting confirmation, and confirmed grants +

+
+
+ ); +} + +export function ConferenceAnalytics({ + comparisonConferences, + conference, +}: { + comparisonConferences: AnalyticsConferenceData[]; + conference: AnalyticsConferenceData; +}) { + const conferences = [conference, ...comparisonConferences]; + const chartSeries = (chartId: string) => + conferences.flatMap((seriesConference) => { + const chart = seriesConference.analytics.find( + (candidate) => candidate.id === chartId, + ); + + return chart ? [{ chart, conference: seriesConference }] : []; + }); + const ticketSales = chartSeries("ticket-sales"); + const proposals = chartSeries("proposals-received"); + const grants = chartSeries("grants-received"); + + if (!ticketSales.length || !proposals.length || !grants.length) { + return null; + } + + return ( +
+

+ Conference analytics +

+ +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/backend/dashboard/src/components/conference-comparison-picker.tsx b/backend/dashboard/src/components/conference-comparison-picker.tsx new file mode 100644 index 0000000000..5555fff8df --- /dev/null +++ b/backend/dashboard/src/components/conference-comparison-picker.tsx @@ -0,0 +1,125 @@ +import { router } from "@inertiajs/react"; +import { GitCompareArrowsIcon } from "lucide-react"; + +import type { ConferenceData } from "@/components/conference-switcher"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { dashboardUrl } from "@/dashboard-url"; + +export function ConferenceComparisonPicker({ + comparisonConferences, + conferences, + maxComparisonConferences, + selectedConference, +}: { + comparisonConferences: ConferenceData[]; + conferences: ConferenceData[]; + maxComparisonConferences: number; + selectedConference: ConferenceData; +}) { + const comparisonCodes = new Set( + comparisonConferences.map((conference) => conference.code), + ); + const selectedCount = comparisonConferences.length + 1; + const comparisonLimitReached = + comparisonConferences.length >= maxComparisonConferences; + + const updateComparison = (code: string, checked: boolean) => { + const nextCodes = new Set(comparisonCodes); + + if (checked) { + nextCodes.add(code); + } else { + nextCodes.delete(code); + } + + const orderedCodes = conferences + .filter((conference) => nextCodes.has(conference.code)) + .map((conference) => conference.code); + + router.visit( + dashboardUrl(selectedConference.code, { + comparisonCodes: orderedCodes, + }), + { + preserveScroll: true, + preserveState: true, + }, + ); + }; + + return ( + + + + + + + Compare up to {maxComparisonConferences + 1} conferences + + {conferences.map((conference) => { + const isPrimary = conference.code === selectedConference.code; + + return ( + + updateComparison(conference.code, checked === true) + } + onSelect={(event) => event.preventDefault()} + > +
+

{conference.name}

+

+ {isPrimary ? "Primary conference" : conference.organizer} +

+
+
+ ); + })} + {comparisonConferences.length ? ( + <> + + + router.visit(dashboardUrl(selectedConference.code), { + preserveScroll: true, + preserveState: true, + }) + } + > + Clear comparison + + + ) : null} +
+
+ ); +} diff --git a/backend/dashboard/src/components/conference-switcher.tsx b/backend/dashboard/src/components/conference-switcher.tsx index d5803b4b36..63c410715a 100644 --- a/backend/dashboard/src/components/conference-switcher.tsx +++ b/backend/dashboard/src/components/conference-switcher.tsx @@ -14,6 +14,7 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; +import { dashboardUrl } from "@/dashboard-url"; export type ConferenceData = { code: string; @@ -102,9 +103,7 @@ export function ConferenceSwitcher({ className="gap-2 p-2" key={conference.code} > - +