diff --git a/client/package-lock.json b/client/package-lock.json index 37b7b4d..18305a0 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.23.1" + "react-router-dom": "^6.23.1", + "recharts": "^3.8.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.0", @@ -740,6 +741,42 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "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 + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -1106,6 +1143,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1151,6 +1200,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1158,6 +1270,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1247,6 +1365,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1254,6 +1381,127 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1272,6 +1520,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.321", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", @@ -1279,6 +1533,16 @@ "dev": true, "license": "ISC" }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1328,6 +1592,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1353,6 +1623,25 @@ "node": ">=6.9.0" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1501,6 +1790,36 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1543,6 +1862,57 @@ "react-dom": ">=16.8" } }, + "node_modules/recharts": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", + "integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "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" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -1617,6 +1987,12 @@ "node": ">=0.10.0" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1648,6 +2024,37 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/client/package.json b/client/package.json index 003f89d..69f66b1 100644 --- a/client/package.json +++ b/client/package.json @@ -9,7 +9,8 @@ "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.23.1" + "react-router-dom": "^6.23.1", + "recharts": "^3.8.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.0", diff --git a/client/src/pages/AnnualOverview.jsx b/client/src/pages/AnnualOverview.jsx index f52516e..ad799d4 100644 --- a/client/src/pages/AnnualOverview.jsx +++ b/client/src/pages/AnnualOverview.jsx @@ -1,10 +1,18 @@ import { useState, useEffect } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, + Cell, ResponsiveContainer, ReferenceLine, +} from 'recharts'; const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; +const MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +const PALETTE = ['#3b82f6', '#8b5cf6', '#ec4899', '#f97316', '#22c55e', '#14b8a6', '#eab308', '#64748b']; + function fmt(value) { if (value == null) return '—'; const num = Number(value); @@ -13,6 +21,14 @@ function fmt(value) { return num < 0 ? `-$${abs}` : `$${abs}`; } +function formatCurrencyShort(value) { + if (value == null || isNaN(value)) return ''; + const abs = Math.abs(value); + const sign = value < 0 ? '-' : ''; + if (abs >= 1000) return `${sign}$${(abs / 1000).toFixed(1)}k`; + return `${sign}$${abs.toFixed(0)}`; +} + function surplusClass(value) { if (value == null || isNaN(Number(value))) return ''; return Number(value) >= 0 ? 'text-success' : 'text-danger'; @@ -25,10 +41,98 @@ function sumField(rows, field) { }, 0); } +// Income vs Spending grouped bar chart +function IncomeVsSpendingChart({ months }) { + const data = MONTH_SHORT.map((label, i) => { + const m = months.find(r => r.month === i + 1); + return { + month: label, + Income: m ? m.income_net : 0, + Spending: m ? m.total_spending : 0, + }; + }); + + return ( + + + + + + fmt(val)} /> + + + + + + ); +} + +// Surplus / Deficit bar chart — green positive, red negative +function SurplusChart({ months }) { + const data = MONTH_SHORT.map((label, i) => { + const m = months.find(r => r.month === i + 1); + return { month: label, value: m ? m.surplus_deficit : null }; + }); + + return ( + + + + + + fmt(val)} label="Surplus / Deficit" /> + + + {data.map((entry, i) => ( + = 0 ? '#22c55e' : '#ef4444'} + /> + ))} + + + + ); +} + +// Stacked bar chart — variable spending by category across months +function StackedCategoryChart({ months, categories }) { + if (!categories || categories.length === 0) { + return

No variable spending data

; + } + + const data = MONTH_SHORT.map((label, i) => { + const m = months.find(r => r.month === i + 1); + const entry = { month: label }; + if (m) { + for (const cat of m.variable_by_category) { + entry[cat.category] = cat.total; + } + } + return entry; + }); + + return ( + + + + + + fmt(val)} /> + + {categories.map((cat, i) => ( + + ))} + + + ); +} + export default function AnnualOverview() { const currentYear = new Date().getFullYear(); const [year, setYear] = useState(currentYear); - const [monthData, setMonthData] = useState(Array(12).fill(null)); + const [annualData, setAnnualData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -36,42 +140,35 @@ export default function AnnualOverview() { let cancelled = false; setLoading(true); setError(null); + setAnnualData(null); - function normalize(data) { - if (!data) return null; - return { - total_income: data.income?.net ?? 0, - total_bills: data.bills?.planned ?? 0, - total_variable: data.actuals?.total ?? 0, - total_one_time: data.one_time_expenses?.total ?? 0, - total_spending: data.summary?.total_spending ?? 0, - surplus_deficit: data.summary?.surplus_deficit ?? 0, - }; - } - - Promise.all( - Array.from({ length: 12 }, (_, i) => - fetch(`/api/summary/monthly?year=${year}&month=${i + 1}`) - .then(r => r.ok ? r.json().then(normalize) : null) - .catch(() => null) - ) - ).then(results => { - if (!cancelled) { setMonthData(results); setLoading(false); } - }).catch(err => { - if (!cancelled) { setError(err.message || 'Failed to load data'); setLoading(false); } - }); + fetch(`/api/summary/annual?year=${year}`) + .then(r => { + if (!r.ok) throw new Error(`Server error: ${r.status}`); + return r.json(); + }) + .then(data => { if (!cancelled) { setAnnualData(data); setLoading(false); } }) + .catch(err => { if (!cancelled) { setError(err.message); setLoading(false); } }); return () => { cancelled = true; }; }, [year]); - const hasData = monthData.some(row => row != null); + const months = annualData?.months ?? []; + const categories = annualData?.categories ?? []; + const hasData = months.length > 0; - const annualIncome = sumField(monthData, 'total_income'); - const annualBills = sumField(monthData, 'total_bills'); - const annualVariable = sumField(monthData, 'total_variable'); - const annualOneTime = sumField(monthData, 'total_one_time'); - const annualSpending = sumField(monthData, 'total_spending'); - const annualSurplus = sumField(monthData, 'surplus_deficit'); + const annualIncome = sumField(months, 'income_net'); + const annualBills = sumField(months, 'total_bills'); + const annualVariable = sumField(months, 'total_variable'); + const annualOneTime = sumField(months, 'total_one_time'); + const annualSpending = sumField(months, 'total_spending'); + const annualSurplus = sumField(months, 'surplus_deficit'); + + // Build full 12-row table (show — for months without data) + const tableRows = MONTH_NAMES.map((name, i) => { + const row = months.find(m => m.month === i + 1) || null; + return { name, row }; + }); return (
@@ -105,54 +202,71 @@ export default function AnnualOverview() { {loading &&

Loading…

} {error &&
Error: {error}
} -
- - - - - - - - - - - - - - {MONTH_NAMES.map((name, i) => { - const row = monthData[i]; - const hasRow = row != null; - const surplus = hasRow ? row.surplus_deficit : null; - return ( - - - - - - - -
MonthIncome (net)BillsVariableOne-timeTotal SpendingSurplus / Deficit
{name}{hasRow ? fmt(row.total_income) : '—'}{hasRow ? fmt(row.total_bills) : '—'}{hasRow ? fmt(row.total_variable) : '—'}{hasRow ? fmt(row.total_one_time) : '—'}{hasRow ? fmt(row.total_spending) : '—'} - {hasRow ? fmt(surplus) : '—'} + {!loading && hasData && ( + <> + {/* Charts */} +
+
+
Income vs. Spending
+ +
+
+
Surplus / Deficit by Month
+ +
+
+ +
+
Variable Spending by Category
+ +
+ + {/* Table */} +
+ + + + + + + + + + + + + + {tableRows.map(({ name, row }) => ( + + + + + + + + + + ))} + + + + + + + + + + - ); - })} - - - - - - - - - - - - -
MonthIncome (net)BillsVariableOne-timeTotal SpendingSurplus / Deficit
{name}{row ? fmt(row.income_net) : '—'}{row ? fmt(row.total_bills) : '—'}{row ? fmt(row.total_variable) : '—'}{row ? fmt(row.total_one_time) : '—'}{row ? fmt(row.total_spending) : '—'} + {row ? fmt(row.surplus_deficit) : '—'} +
Total{fmt(annualIncome)}{fmt(annualBills)}{fmt(annualVariable)}{fmt(annualOneTime)}{fmt(annualSpending)} + {fmt(annualSurplus)}
Total{hasData ? fmt(annualIncome) : '—'}{hasData ? fmt(annualBills) : '—'}{hasData ? fmt(annualVariable) : '—'}{hasData ? fmt(annualOneTime) : '—'}{hasData ? fmt(annualSpending) : '—'} - {hasData ? fmt(annualSurplus) : '—'} -
-
+ +
+
+ + )}
); } diff --git a/client/src/pages/MonthlySummary.jsx b/client/src/pages/MonthlySummary.jsx index cdefa4f..a2435bc 100644 --- a/client/src/pages/MonthlySummary.jsx +++ b/client/src/pages/MonthlySummary.jsx @@ -1,4 +1,8 @@ import { useState, useEffect } from 'react'; +import { + PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, + BarChart, Bar, XAxis, YAxis, CartesianGrid, +} from 'recharts'; const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', @@ -10,6 +14,15 @@ function formatCurrency(value) { return '$' + num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } +function formatCurrencyShort(value) { + const num = parseFloat(value) || 0; + if (Math.abs(num) >= 1000) return '$' + (num / 1000).toFixed(1) + 'k'; + return '$' + num.toFixed(0); +} + +// Accessible palette that works in light and dark +const PALETTE = ['#3b82f6', '#8b5cf6', '#ec4899', '#f97316', '#22c55e', '#14b8a6', '#eab308', '#64748b']; + function StatCard({ label, value, valueClass }) { return (
@@ -19,6 +32,58 @@ function StatCard({ label, value, valueClass }) { ); } +function SpendingDonut({ bills, variable, oneTime }) { + const data = [ + { name: 'Bills', value: bills }, + { name: 'Variable', value: variable }, + { name: 'One-time', value: oneTime }, + ].filter(d => d.value > 0); + + if (data.length === 0) return

No spending data

; + + return ( + + + + {data.map((_, i) => ( + + ))} + + formatCurrency(val)} /> + + + + ); +} + +function CategoryBar({ data }) { + if (!data || data.length === 0) return

No variable spending data

; + + return ( + + + + + + formatCurrency(val)} /> + + {data.map((_, i) => ( + + ))} + + + + ); +} + function MonthlySummary() { const now = new Date(); const [year, setYear] = useState(now.getFullYear()); @@ -82,6 +147,22 @@ function MonthlySummary() {
+ {/* Charts row */} +
+
+
Spending Breakdown
+ +
+
+
Variable Spending by Category
+ +
+
+
diff --git a/server/src/routes/summary.js b/server/src/routes/summary.js index c72eb11..e144f55 100644 --- a/server/src/routes/summary.js +++ b/server/src/routes/summary.js @@ -64,7 +64,7 @@ router.get('/summary/monthly', async (req, res) => { const billsCount = billsRow.count || 0; const billsPaidCount = billsRow.paid_count || 0; - // Actuals aggregates + // Actuals aggregates + per-category breakdown const actualsResult = await pool.query( `SELECT COUNT(*)::int AS count, COALESCE(SUM(amount), 0) AS total FROM actuals @@ -72,9 +72,24 @@ router.get('/summary/monthly', async (req, res) => { [paycheckIds] ); + const actualsCategoryResult = await pool.query( + `SELECT COALESCE(ec.name, 'Uncategorized') AS category, + COALESCE(SUM(a.amount), 0) AS total + FROM actuals a + LEFT JOIN expense_categories ec ON ec.id = a.category_id + WHERE a.paycheck_id = ANY($1) + GROUP BY COALESCE(ec.name, 'Uncategorized') + ORDER BY total DESC`, + [paycheckIds] + ); + const actualsRow = actualsResult.rows[0]; const actualsTotal = parseFloat(actualsRow.total) || 0; const actualsCount = actualsRow.count || 0; + const variableByCategory = actualsCategoryResult.rows.map(r => ({ + category: r.category, + total: parseFloat(r.total) || 0, + })); // One-time expenses aggregates const oteResult = await pool.query( @@ -109,6 +124,7 @@ router.get('/summary/monthly', async (req, res) => { actuals: { total: parseFloat(actualsTotal.toFixed(2)), count: actualsCount, + by_category: variableByCategory, }, one_time_expenses: { total: parseFloat(oteTotal.toFixed(2)), @@ -125,4 +141,125 @@ router.get('/summary/monthly', async (req, res) => { } }); +// GET /api/summary/annual?year= +router.get('/summary/annual', async (req, res) => { + const year = parseInt(req.query.year, 10); + if (isNaN(year)) { + return res.status(400).json({ error: 'year is required' }); + } + + try { + // All paychecks for the year + const pcResult = await pool.query( + `SELECT id, period_month, gross, net FROM paychecks + WHERE period_year = $1 ORDER BY period_month`, + [year] + ); + + // Per-month income totals + const monthIncome = {}; + for (const r of pcResult.rows) { + const m = r.period_month; + if (!monthIncome[m]) monthIncome[m] = { gross: 0, net: 0 }; + monthIncome[m].gross += parseFloat(r.gross) || 0; + monthIncome[m].net += parseFloat(r.net) || 0; + } + + const paycheckIds = pcResult.rows.map(r => r.id); + + if (paycheckIds.length === 0) { + return res.json({ year, months: [], categories: [] }); + } + + // Per-month bill totals + const billsResult = await pool.query( + `SELECT p.period_month, + COALESCE(SUM(CASE WHEN pb.amount_override IS NOT NULL THEN pb.amount_override ELSE b.amount END), 0) AS planned + FROM paycheck_bills pb + JOIN bills b ON b.id = pb.bill_id + JOIN paychecks p ON p.id = pb.paycheck_id + WHERE pb.paycheck_id = ANY($1) + GROUP BY p.period_month`, + [paycheckIds] + ); + + const monthBills = {}; + for (const r of billsResult.rows) { + monthBills[r.period_month] = parseFloat(r.planned) || 0; + } + + // Per-month one-time expense totals + const oteResult = await pool.query( + `SELECT p.period_month, COALESCE(SUM(ote.amount), 0) AS total + FROM one_time_expenses ote + JOIN paychecks p ON p.id = ote.paycheck_id + WHERE ote.paycheck_id = ANY($1) + GROUP BY p.period_month`, + [paycheckIds] + ); + + const monthOte = {}; + for (const r of oteResult.rows) { + monthOte[r.period_month] = parseFloat(r.total) || 0; + } + + // Per-month actuals by category + const actualsResult = await pool.query( + `SELECT p.period_month, + COALESCE(ec.name, 'Uncategorized') AS category, + COALESCE(SUM(a.amount), 0) AS total + FROM actuals a + JOIN paychecks p ON p.id = a.paycheck_id + LEFT JOIN expense_categories ec ON ec.id = a.category_id + WHERE a.paycheck_id = ANY($1) + GROUP BY p.period_month, COALESCE(ec.name, 'Uncategorized') + ORDER BY p.period_month, total DESC`, + [paycheckIds] + ); + + // Collect all unique categories + const categorySet = new Set(); + const monthActualsByCategory = {}; + for (const r of actualsResult.rows) { + const m = r.period_month; + categorySet.add(r.category); + if (!monthActualsByCategory[m]) monthActualsByCategory[m] = {}; + monthActualsByCategory[m][r.category] = parseFloat(r.total) || 0; + } + + const categories = [...categorySet]; + + // Build months array (only months with paychecks) + const monthSet = new Set(pcResult.rows.map(r => r.period_month)); + const months = [...monthSet].sort((a, b) => a - b).map(m => { + const income = monthIncome[m] || { gross: 0, net: 0 }; + const bills = monthBills[m] || 0; + const ote = monthOte[m] || 0; + const catData = monthActualsByCategory[m] || {}; + const variable = Object.values(catData).reduce((s, v) => s + v, 0); + const spending = bills + variable + ote; + + return { + month: m, + month_name: MONTH_NAMES[m - 1], + income_net: parseFloat(income.net.toFixed(2)), + total_bills: parseFloat(bills.toFixed(2)), + total_variable: parseFloat(variable.toFixed(2)), + total_one_time: parseFloat(ote.toFixed(2)), + total_spending: parseFloat(spending.toFixed(2)), + surplus_deficit: parseFloat((income.net - spending).toFixed(2)), + variable_by_category: categories.map(cat => ({ + category: cat, + total: parseFloat((catData[cat] || 0).toFixed(2)), + })), + }; + }); + + res.json({ year, months, categories }); + } catch (err) { + console.error('GET /api/summary/annual error:', err); + res.status(500).json({ error: 'Failed to fetch annual summary' }); + } +}); + module.exports = router;