Add Recharts charts to Monthly Summary and Annual Overview
Monthly Summary: - Spending breakdown donut (bills / variable / one-time) - Variable spending by category bar chart - Added actuals.by_category to /api/summary/monthly response Annual Overview: - Income vs. spending grouped bar chart - Surplus/deficit bar chart (green/red per month) - Stacked variable spending by category across all months - New /api/summary/annual endpoint (single DB round trip for full year) - AnnualOverview now uses /api/summary/annual instead of 12 parallel calls Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
409
client/package-lock.json
generated
409
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
|
||||
<YAxis tickFormatter={formatCurrencyShort} tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
|
||||
<Tooltip formatter={(val) => fmt(val)} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Bar dataKey="Income" fill="#22c55e" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="Spending" fill="#3b82f6" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
|
||||
<YAxis tickFormatter={formatCurrencyShort} tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
|
||||
<Tooltip formatter={(val) => fmt(val)} label="Surplus / Deficit" />
|
||||
<ReferenceLine y={0} stroke="var(--border-strong)" />
|
||||
<Bar dataKey="value" name="Surplus / Deficit" radius={[3, 3, 0, 0]}>
|
||||
{data.map((entry, i) => (
|
||||
<Cell
|
||||
key={i}
|
||||
fill={entry.value == null ? 'var(--border)' : entry.value >= 0 ? '#22c55e' : '#ef4444'}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Stacked bar chart — variable spending by category across months
|
||||
function StackedCategoryChart({ months, categories }) {
|
||||
if (!categories || categories.length === 0) {
|
||||
return <p className="empty-state">No variable spending data</p>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
|
||||
<YAxis tickFormatter={formatCurrencyShort} tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
|
||||
<Tooltip formatter={(val) => fmt(val)} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{categories.map((cat, i) => (
|
||||
<Bar key={cat} dataKey={cat} stackId="a" fill={PALETTE[i % PALETTE.length]}
|
||||
radius={i === categories.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
@@ -105,54 +202,71 @@ export default function AnnualOverview() {
|
||||
{loading && <p className="text-muted">Loading…</p>}
|
||||
{error && <div className="alert alert-error">Error: {error}</div>}
|
||||
|
||||
<div className="card" style={{ overflow: 'hidden' }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th className="text-right">Income (net)</th>
|
||||
<th className="text-right">Bills</th>
|
||||
<th className="text-right">Variable</th>
|
||||
<th className="text-right">One-time</th>
|
||||
<th className="text-right">Total Spending</th>
|
||||
<th className="text-right">Surplus / Deficit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MONTH_NAMES.map((name, i) => {
|
||||
const row = monthData[i];
|
||||
const hasRow = row != null;
|
||||
const surplus = hasRow ? row.surplus_deficit : null;
|
||||
return (
|
||||
<tr key={name}>
|
||||
<td>{name}</td>
|
||||
<td className="text-right font-tabular">{hasRow ? fmt(row.total_income) : '—'}</td>
|
||||
<td className="text-right font-tabular">{hasRow ? fmt(row.total_bills) : '—'}</td>
|
||||
<td className="text-right font-tabular">{hasRow ? fmt(row.total_variable) : '—'}</td>
|
||||
<td className="text-right font-tabular">{hasRow ? fmt(row.total_one_time) : '—'}</td>
|
||||
<td className="text-right font-tabular">{hasRow ? fmt(row.total_spending) : '—'}</td>
|
||||
<td className={`text-right font-tabular ${surplusClass(surplus)}`}>
|
||||
{hasRow ? fmt(surplus) : '—'}
|
||||
{!loading && hasData && (
|
||||
<>
|
||||
{/* Charts */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.25rem', marginBottom: '1.25rem' }}>
|
||||
<div className="card card-body">
|
||||
<div className="section-title" style={{ marginBottom: '0.75rem' }}>Income vs. Spending</div>
|
||||
<IncomeVsSpendingChart months={months} />
|
||||
</div>
|
||||
<div className="card card-body">
|
||||
<div className="section-title" style={{ marginBottom: '0.75rem' }}>Surplus / Deficit by Month</div>
|
||||
<SurplusChart months={months} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card card-body" style={{ marginBottom: '1.25rem' }}>
|
||||
<div className="section-title" style={{ marginBottom: '0.75rem' }}>Variable Spending by Category</div>
|
||||
<StackedCategoryChart months={months} categories={categories} />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card" style={{ overflow: 'hidden' }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th className="text-right">Income (net)</th>
|
||||
<th className="text-right">Bills</th>
|
||||
<th className="text-right">Variable</th>
|
||||
<th className="text-right">One-time</th>
|
||||
<th className="text-right">Total Spending</th>
|
||||
<th className="text-right">Surplus / Deficit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableRows.map(({ name, row }) => (
|
||||
<tr key={name}>
|
||||
<td>{name}</td>
|
||||
<td className="text-right font-tabular">{row ? fmt(row.income_net) : '—'}</td>
|
||||
<td className="text-right font-tabular">{row ? fmt(row.total_bills) : '—'}</td>
|
||||
<td className="text-right font-tabular">{row ? fmt(row.total_variable) : '—'}</td>
|
||||
<td className="text-right font-tabular">{row ? fmt(row.total_one_time) : '—'}</td>
|
||||
<td className="text-right font-tabular">{row ? fmt(row.total_spending) : '—'}</td>
|
||||
<td className={`text-right font-tabular ${row ? surplusClass(row.surplus_deficit) : ''}`}>
|
||||
{row ? fmt(row.surplus_deficit) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td className="font-bold">Total</td>
|
||||
<td className="text-right font-tabular">{fmt(annualIncome)}</td>
|
||||
<td className="text-right font-tabular">{fmt(annualBills)}</td>
|
||||
<td className="text-right font-tabular">{fmt(annualVariable)}</td>
|
||||
<td className="text-right font-tabular">{fmt(annualOneTime)}</td>
|
||||
<td className="text-right font-tabular">{fmt(annualSpending)}</td>
|
||||
<td className={`text-right font-tabular font-bold ${surplusClass(annualSurplus)}`}>
|
||||
{fmt(annualSurplus)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td className="font-bold">Total</td>
|
||||
<td className="text-right font-tabular">{hasData ? fmt(annualIncome) : '—'}</td>
|
||||
<td className="text-right font-tabular">{hasData ? fmt(annualBills) : '—'}</td>
|
||||
<td className="text-right font-tabular">{hasData ? fmt(annualVariable) : '—'}</td>
|
||||
<td className="text-right font-tabular">{hasData ? fmt(annualOneTime) : '—'}</td>
|
||||
<td className="text-right font-tabular">{hasData ? fmt(annualSpending) : '—'}</td>
|
||||
<td className={`text-right font-tabular font-bold ${surplusClass(annualSurplus)}`}>
|
||||
{hasData ? fmt(annualSurplus) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="stat-card">
|
||||
@@ -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 <p className="empty-state">No spending data</p>;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={PALETTE[i % PALETTE.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(val) => formatCurrency(val)} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryBar({ data }) {
|
||||
if (!data || data.length === 0) return <p className="empty-state">No variable spending data</p>;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis dataKey="category" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
|
||||
<YAxis tickFormatter={formatCurrencyShort} tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
|
||||
<Tooltip formatter={(val) => formatCurrency(val)} />
|
||||
<Bar dataKey="total" name="Spending" radius={[4, 4, 0, 0]}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={PALETTE[i % PALETTE.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthlySummary() {
|
||||
const now = new Date();
|
||||
const [year, setYear] = useState(now.getFullYear());
|
||||
@@ -82,6 +147,22 @@ function MonthlySummary() {
|
||||
<StatCard label="Bills Paid" value={`${data.bills.paid_count} of ${data.bills.count}`} />
|
||||
</div>
|
||||
|
||||
{/* Charts row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.25rem', marginBottom: '1.25rem' }}>
|
||||
<div className="card card-body">
|
||||
<div className="section-title" style={{ marginBottom: '0.75rem' }}>Spending Breakdown</div>
|
||||
<SpendingDonut
|
||||
bills={data.bills.planned}
|
||||
variable={data.actuals.total}
|
||||
oneTime={data.one_time_expenses.total}
|
||||
/>
|
||||
</div>
|
||||
<div className="card card-body">
|
||||
<div className="section-title" style={{ marginBottom: '0.75rem' }}>Variable Spending by Category</div>
|
||||
<CategoryBar data={data.actuals.by_category} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ overflow: 'hidden' }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user