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:
2026-03-19 19:58:50 -04:00
parent ea2ee9c5e6
commit 195a36c8a5
5 changed files with 820 additions and 80 deletions

409
client/package-lock.json generated
View File

@@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^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": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
@@ -740,6 +741,42 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@remix-run/router": {
"version": "1.23.2", "version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -1106,6 +1143,18 @@
"win32" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1151,6 +1200,69 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1158,6 +1270,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1247,6 +1365,15 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1254,6 +1381,127 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "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": { "node_modules/electron-to-chromium": {
"version": "1.5.321", "version": "1.5.321",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
@@ -1279,6 +1533,16 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/esbuild": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1328,6 +1592,12 @@
"node": ">=6" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1353,6 +1623,25 @@
"node": ">=6.9.0" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1501,6 +1790,36 @@
"react": "^18.3.1" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -1543,6 +1862,57 @@
"react-dom": ">=16.8" "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": { "node_modules/rollup": {
"version": "4.59.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -1617,6 +1987,12 @@
"node": ">=0.10.0" "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": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -1648,6 +2024,37 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",

View File

@@ -9,7 +9,8 @@
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^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": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",

View File

@@ -1,10 +1,18 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
Cell, ResponsiveContainer, ReferenceLine,
} from 'recharts';
const MONTH_NAMES = [ const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December', '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) { function fmt(value) {
if (value == null) return '—'; if (value == null) return '—';
const num = Number(value); const num = Number(value);
@@ -13,6 +21,14 @@ function fmt(value) {
return num < 0 ? `-$${abs}` : `$${abs}`; 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) { function surplusClass(value) {
if (value == null || isNaN(Number(value))) return ''; if (value == null || isNaN(Number(value))) return '';
return Number(value) >= 0 ? 'text-success' : 'text-danger'; return Number(value) >= 0 ? 'text-success' : 'text-danger';
@@ -25,10 +41,98 @@ function sumField(rows, field) {
}, 0); }, 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() { export default function AnnualOverview() {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const [year, setYear] = useState(currentYear); const [year, setYear] = useState(currentYear);
const [monthData, setMonthData] = useState(Array(12).fill(null)); const [annualData, setAnnualData] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -36,42 +140,35 @@ export default function AnnualOverview() {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setError(null); setError(null);
setAnnualData(null);
function normalize(data) { fetch(`/api/summary/annual?year=${year}`)
if (!data) return null; .then(r => {
return { if (!r.ok) throw new Error(`Server error: ${r.status}`);
total_income: data.income?.net ?? 0, return r.json();
total_bills: data.bills?.planned ?? 0, })
total_variable: data.actuals?.total ?? 0, .then(data => { if (!cancelled) { setAnnualData(data); setLoading(false); } })
total_one_time: data.one_time_expenses?.total ?? 0, .catch(err => { if (!cancelled) { setError(err.message); setLoading(false); } });
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); }
});
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [year]); }, [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 annualIncome = sumField(months, 'income_net');
const annualBills = sumField(monthData, 'total_bills'); const annualBills = sumField(months, 'total_bills');
const annualVariable = sumField(monthData, 'total_variable'); const annualVariable = sumField(months, 'total_variable');
const annualOneTime = sumField(monthData, 'total_one_time'); const annualOneTime = sumField(months, 'total_one_time');
const annualSpending = sumField(monthData, 'total_spending'); const annualSpending = sumField(months, 'total_spending');
const annualSurplus = sumField(monthData, 'surplus_deficit'); 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 ( return (
<div> <div>
@@ -105,54 +202,71 @@ export default function AnnualOverview() {
{loading && <p className="text-muted">Loading</p>} {loading && <p className="text-muted">Loading</p>}
{error && <div className="alert alert-error">Error: {error}</div>} {error && <div className="alert alert-error">Error: {error}</div>}
<div className="card" style={{ overflow: 'hidden' }}> {!loading && hasData && (
<table className="data-table"> <>
<thead> {/* Charts */}
<tr> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.25rem', marginBottom: '1.25rem' }}>
<th>Month</th> <div className="card card-body">
<th className="text-right">Income (net)</th> <div className="section-title" style={{ marginBottom: '0.75rem' }}>Income vs. Spending</div>
<th className="text-right">Bills</th> <IncomeVsSpendingChart months={months} />
<th className="text-right">Variable</th> </div>
<th className="text-right">One-time</th> <div className="card card-body">
<th className="text-right">Total Spending</th> <div className="section-title" style={{ marginBottom: '0.75rem' }}>Surplus / Deficit by Month</div>
<th className="text-right">Surplus / Deficit</th> <SurplusChart months={months} />
</tr> </div>
</thead> </div>
<tbody>
{MONTH_NAMES.map((name, i) => { <div className="card card-body" style={{ marginBottom: '1.25rem' }}>
const row = monthData[i]; <div className="section-title" style={{ marginBottom: '0.75rem' }}>Variable Spending by Category</div>
const hasRow = row != null; <StackedCategoryChart months={months} categories={categories} />
const surplus = hasRow ? row.surplus_deficit : null; </div>
return (
<tr key={name}> {/* Table */}
<td>{name}</td> <div className="card" style={{ overflow: 'hidden' }}>
<td className="text-right font-tabular">{hasRow ? fmt(row.total_income) : '—'}</td> <table className="data-table">
<td className="text-right font-tabular">{hasRow ? fmt(row.total_bills) : '—'}</td> <thead>
<td className="text-right font-tabular">{hasRow ? fmt(row.total_variable) : '—'}</td> <tr>
<td className="text-right font-tabular">{hasRow ? fmt(row.total_one_time) : '—'}</td> <th>Month</th>
<td className="text-right font-tabular">{hasRow ? fmt(row.total_spending) : '—'}</td> <th className="text-right">Income (net)</th>
<td className={`text-right font-tabular ${surplusClass(surplus)}`}> <th className="text-right">Bills</th>
{hasRow ? fmt(surplus) : '—'} <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> </td>
</tr> </tr>
); </tfoot>
})} </table>
</tbody> </div>
<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>
</div> </div>
); );
} }

View File

@@ -1,4 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import {
PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
} from 'recharts';
const MONTH_NAMES = [ const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June', 'January', 'February', 'March', 'April', 'May', 'June',
@@ -10,6 +14,15 @@ function formatCurrency(value) {
return '$' + num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 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 }) { function StatCard({ label, value, valueClass }) {
return ( return (
<div className="stat-card"> <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() { function MonthlySummary() {
const now = new Date(); const now = new Date();
const [year, setYear] = useState(now.getFullYear()); 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}`} /> <StatCard label="Bills Paid" value={`${data.bills.paid_count} of ${data.bills.count}`} />
</div> </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' }}> <div className="card" style={{ overflow: 'hidden' }}>
<table className="data-table"> <table className="data-table">
<thead> <thead>

View File

@@ -64,7 +64,7 @@ router.get('/summary/monthly', async (req, res) => {
const billsCount = billsRow.count || 0; const billsCount = billsRow.count || 0;
const billsPaidCount = billsRow.paid_count || 0; const billsPaidCount = billsRow.paid_count || 0;
// Actuals aggregates // Actuals aggregates + per-category breakdown
const actualsResult = await pool.query( const actualsResult = await pool.query(
`SELECT COUNT(*)::int AS count, COALESCE(SUM(amount), 0) AS total `SELECT COUNT(*)::int AS count, COALESCE(SUM(amount), 0) AS total
FROM actuals FROM actuals
@@ -72,9 +72,24 @@ router.get('/summary/monthly', async (req, res) => {
[paycheckIds] [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 actualsRow = actualsResult.rows[0];
const actualsTotal = parseFloat(actualsRow.total) || 0; const actualsTotal = parseFloat(actualsRow.total) || 0;
const actualsCount = actualsRow.count || 0; const actualsCount = actualsRow.count || 0;
const variableByCategory = actualsCategoryResult.rows.map(r => ({
category: r.category,
total: parseFloat(r.total) || 0,
}));
// One-time expenses aggregates // One-time expenses aggregates
const oteResult = await pool.query( const oteResult = await pool.query(
@@ -109,6 +124,7 @@ router.get('/summary/monthly', async (req, res) => {
actuals: { actuals: {
total: parseFloat(actualsTotal.toFixed(2)), total: parseFloat(actualsTotal.toFixed(2)),
count: actualsCount, count: actualsCount,
by_category: variableByCategory,
}, },
one_time_expenses: { one_time_expenses: {
total: parseFloat(oteTotal.toFixed(2)), 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; module.exports = router;