feat(analytics): replace budget page with spending analytics + split-adjusted amounts
- Rename 'Budget' → 'Analytics' in sidebar - Rewrite /budget page: summary cards, recharts bar charts (monthly trend + category breakdown), 6-month trend table - Fix analytics API to count only user's share for split transactions (CASE WHEN ts.share_percent IS NOT NULL THEN amount * share_percent / 100 ELSE amount END) - Install recharts
This commit is contained in:
Generated
+395
-2
@@ -15,7 +15,8 @@
|
||||
"pg": "^8.20.0",
|
||||
"prisma": "^7.4.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -1490,6 +1491,42 @@
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"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/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1503,6 +1540,12 @@
|
||||
"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/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -1820,6 +1863,69 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -1882,6 +1988,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"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/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
||||
@@ -2987,6 +3099,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -3056,6 +3177,127 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"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/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -3135,6 +3377,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/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3482,6 +3730,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"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/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -3935,6 +4193,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"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/exsolve": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||
@@ -4509,6 +4773,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"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/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -4551,6 +4825,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"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/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -6386,9 +6669,31 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -6402,6 +6707,51 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"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/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -6461,6 +6811,12 @@
|
||||
"url": "https://github.com/sponsors/remeda"
|
||||
}
|
||||
},
|
||||
"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/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -7104,6 +7460,12 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"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/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
@@ -7450,6 +7812,15 @@
|
||||
"punycode": "^2.1.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/valibot": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
|
||||
@@ -7464,6 +7835,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
"pg": "^8.20.0",
|
||||
"prisma": "^7.4.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -25,10 +25,16 @@ export async function GET(req: NextRequest) {
|
||||
`SELECT
|
||||
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||
COALESCE(o.category_override, t.category) as category,
|
||||
SUM(t.amount)::numeric(12,2) as total_spent,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN ts.share_percent IS NOT NULL THEN t.amount * ts.share_percent / 100
|
||||
ELSE t.amount
|
||||
END
|
||||
)::numeric(12,2) as total_spent,
|
||||
COUNT(*)::int as transaction_count
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||
JOIN statements s ON s.id = t.statement_id
|
||||
WHERE s.owner_id = $1
|
||||
AND t.transaction_type = 'debit'
|
||||
|
||||
+241
-267
@@ -2,17 +2,20 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useBudgets,
|
||||
useUpsertBudget,
|
||||
useDeleteBudget,
|
||||
useMonthlyAnalytics,
|
||||
} from "@/lib/hooks";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { useMonthlyAnalytics } from "@/lib/hooks";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
|
||||
function formatMonth(m: string): string {
|
||||
const [year, month] = m.split("-");
|
||||
const date = new Date(Number(year), Number(month) - 1, 1);
|
||||
return date.toLocaleString("default", { month: "long", year: "numeric" });
|
||||
function currentMonthStr(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function prevMonth(m: string): string {
|
||||
@@ -27,66 +30,79 @@ function nextMonth(m: string): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function currentMonthStr(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
function formatMonth(m: string): string {
|
||||
const [year, month] = m.split("-");
|
||||
const date = new Date(Number(year), Number(month) - 1, 1);
|
||||
return date.toLocaleString("default", { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
function barColor(pct: number): string {
|
||||
if (pct > 100) return "bg-red-500";
|
||||
if (pct > 80) return "bg-yellow-400";
|
||||
return "bg-emerald-500";
|
||||
function formatShortMonth(m: string): string {
|
||||
const [year, month] = m.split("-");
|
||||
const date = new Date(Number(year), Number(month) - 1, 1);
|
||||
return date.toLocaleString("default", { month: "short" });
|
||||
}
|
||||
|
||||
export default function BudgetPage() {
|
||||
function fmt(n: number): string {
|
||||
return `$${n.toFixed(0)}`;
|
||||
}
|
||||
|
||||
function fmtExact(n: number): string {
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
|
||||
const [editingCategory, setEditingCategory] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
|
||||
|
||||
const { data: budgets = [], isLoading: budgetsLoading } = useBudgets(selectedMonth);
|
||||
const { data: analytics, isLoading: analyticsLoading } = useMonthlyAnalytics(6);
|
||||
const upsertBudget = useUpsertBudget();
|
||||
const deleteBudget = useDeleteBudget();
|
||||
|
||||
const budgetMap = new Map(budgets.map((b) => [b.category, b]));
|
||||
|
||||
// Categories with spend this month or a budget set
|
||||
const currentMonthRows = analytics?.rows.filter((r) => r.spent[selectedMonth] !== undefined) || [];
|
||||
const allCategories = new Set<string>([
|
||||
...currentMonthRows.map((r) => r.category),
|
||||
...budgets.map((b) => b.category),
|
||||
]);
|
||||
|
||||
const tableRows = Array.from(allCategories)
|
||||
.sort()
|
||||
.map((cat) => {
|
||||
const analyticsRow = analytics?.rows.find((r) => r.category === cat);
|
||||
const spent = analyticsRow?.spent[selectedMonth] || 0;
|
||||
const txCount = analyticsRow?.txCount[selectedMonth] || 0;
|
||||
const budget = budgetMap.get(cat);
|
||||
const limit = budget ? Number(budget.amount_limit) : null;
|
||||
const remaining = limit !== null ? limit - spent : null;
|
||||
const pct = limit !== null && limit > 0 ? (spent / limit) * 100 : null;
|
||||
return { cat, spent, txCount, budget, limit, remaining, pct };
|
||||
});
|
||||
|
||||
const totalBudgeted = budgets.reduce((s, b) => s + Number(b.amount_limit), 0);
|
||||
const totalSpent = tableRows.reduce((s, r) => s + r.spent, 0);
|
||||
const overBudgetCount = tableRows.filter((r) => r.pct !== null && r.pct > 100).length;
|
||||
|
||||
async function handleBudgetSave(cat: string) {
|
||||
const val = parseFloat(editValue);
|
||||
if (isNaN(val) || val < 0) return;
|
||||
await upsertBudget.mutateAsync({ category: cat, month: selectedMonth, amount_limit: val });
|
||||
setEditingCategory(null);
|
||||
setEditValue("");
|
||||
if (isLoading || !analytics) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold">Analytics</h2>
|
||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure selectedMonth is within the available range
|
||||
const months = [...analytics.months].reverse(); // chronological order
|
||||
|
||||
// Per-month totals (selected month)
|
||||
const totalSpent = analytics.totals[selectedMonth]?.spent || 0;
|
||||
|
||||
// Last month for comparison
|
||||
const lastMonth = prevMonth(selectedMonth);
|
||||
const lastMonthSpent = analytics.totals[lastMonth]?.spent || 0;
|
||||
const delta = totalSpent - lastMonthSpent;
|
||||
|
||||
// Category rows for selected month, sorted by spend
|
||||
const categoryRows = analytics.rows
|
||||
.filter((r) => (r.spent[selectedMonth] || 0) > 0)
|
||||
.map((r) => ({
|
||||
category: r.category,
|
||||
spent: r.spent[selectedMonth] || 0,
|
||||
txCount: r.txCount[selectedMonth] || 0,
|
||||
}))
|
||||
.sort((a, b) => b.spent - a.spent);
|
||||
|
||||
const largestCategory = categoryRows[0];
|
||||
|
||||
// Data for monthly trend bar chart
|
||||
const trendData = months.map((m) => ({
|
||||
month: m,
|
||||
label: formatShortMonth(m),
|
||||
spent: analytics.totals[m]?.spent || 0,
|
||||
}));
|
||||
|
||||
// Data for horizontal category bar chart
|
||||
const categoryChartData = [...categoryRows].reverse(); // smallest at top for readability
|
||||
|
||||
const chartHeight = Math.max(categoryChartData.length * 36, 120);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Month selector */}
|
||||
{/* Header + month selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Budget</h2>
|
||||
<h2 className="text-xl font-semibold">Analytics</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedMonth(prevMonth(selectedMonth))}
|
||||
@@ -94,7 +110,9 @@ export default function BudgetPage() {
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="text-sm font-medium min-w-36 text-center">{formatMonth(selectedMonth)}</span>
|
||||
<span className="text-sm font-medium min-w-36 text-center">
|
||||
{formatMonth(selectedMonth)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedMonth(nextMonth(selectedMonth))}
|
||||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
|
||||
@@ -106,210 +124,169 @@ export default function BudgetPage() {
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||
<p className="text-xs text-zinc-500 mb-1">Total Budgeted</p>
|
||||
<p className="text-2xl font-semibold">${totalBudgeted.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||
<p className="text-xs text-zinc-500 mb-1">Total Spent</p>
|
||||
<p
|
||||
className={`text-2xl font-semibold ${
|
||||
totalBudgeted > 0 && totalSpent > totalBudgeted ? "text-red-400" : ""
|
||||
}`}
|
||||
>
|
||||
${totalSpent.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-2xl font-semibold">{fmtExact(totalSpent)}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">split-adjusted</p>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||
<p className="text-xs text-zinc-500 mb-1">Over Budget</p>
|
||||
<p
|
||||
className={`text-2xl font-semibold ${
|
||||
overBudgetCount > 0 ? "text-red-400" : "text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{overBudgetCount} {overBudgetCount === 1 ? "category" : "categories"}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 mb-1">Largest Category</p>
|
||||
{largestCategory ? (
|
||||
<>
|
||||
<p className="text-2xl font-semibold">{fmtExact(largestCategory.spent)}</p>
|
||||
<p className="text-xs text-zinc-400 mt-1">{formatCategory(largestCategory.category)}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-2xl font-semibold text-zinc-600">—</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||
<p className="text-xs text-zinc-500 mb-1">vs Last Month</p>
|
||||
{lastMonthSpent > 0 ? (
|
||||
<>
|
||||
<p
|
||||
className={`text-2xl font-semibold ${
|
||||
delta > 0 ? "text-red-400" : delta < 0 ? "text-emerald-400" : ""
|
||||
}`}
|
||||
>
|
||||
{delta > 0 ? "+" : ""}
|
||||
{fmtExact(delta)}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
{delta > 0 ? "↑ more" : delta < 0 ? "↓ less" : "same"} than {formatShortMonth(lastMonth)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-2xl font-semibold text-zinc-600">—</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budget table for current month */}
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-4 py-3 text-xs text-zinc-500 font-medium">Category</th>
|
||||
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Budget</th>
|
||||
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Spent</th>
|
||||
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Remaining</th>
|
||||
<th className="px-4 py-3 text-xs text-zinc-500 font-medium w-36">Progress</th>
|
||||
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium"># Txns</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{budgetsLoading || analyticsLoading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : tableRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
|
||||
No spending data for this month. Set a budget for any category below.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tableRows.map(({ cat, spent, txCount, budget, limit, remaining, pct }) => (
|
||||
<tr key={cat} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||
<td className="px-4 py-3 font-medium">{formatCategory(cat)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{editingCategory === cat ? (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleBudgetSave(cat);
|
||||
if (e.key === "Escape") {
|
||||
setEditingCategory(null);
|
||||
setEditValue("");
|
||||
}
|
||||
}}
|
||||
className="w-24 bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-right text-sm"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleBudgetSave(cat)}
|
||||
className="text-xs text-emerald-400 hover:text-emerald-300 px-1"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingCategory(null);
|
||||
setEditValue("");
|
||||
}}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 px-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingCategory(cat);
|
||||
setEditValue(limit?.toString() || "");
|
||||
}}
|
||||
className="text-zinc-300 hover:text-white underline-offset-2 hover:underline"
|
||||
>
|
||||
{limit !== null ? (
|
||||
`$${limit.toFixed(2)}`
|
||||
) : (
|
||||
<span className="text-zinc-600 italic">Set...</span>
|
||||
)}
|
||||
</button>
|
||||
{budget && (
|
||||
<button
|
||||
onClick={() => deleteBudget.mutate(budget.id)}
|
||||
className="text-zinc-600 hover:text-red-400 text-xs ml-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">${spent.toFixed(2)}</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-right ${
|
||||
remaining !== null
|
||||
? remaining < 0
|
||||
? "text-red-400"
|
||||
: "text-emerald-400"
|
||||
: "text-zinc-600"
|
||||
}`}
|
||||
>
|
||||
{remaining !== null ? `$${remaining.toFixed(2)}` : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{pct !== null ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-zinc-800 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${barColor(pct)}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-zinc-400 w-10 text-right">
|
||||
{pct.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-zinc-600 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-zinc-400">{txCount}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add budget for any category not yet shown */}
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-2">Set budget for another category:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORIES.filter((c) => !allCategories.has(c)).map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => {
|
||||
setEditingCategory(cat);
|
||||
setEditValue("");
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
|
||||
>
|
||||
{formatCategory(cat)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{editingCategory && !allCategories.has(editingCategory) && (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<span className="text-sm font-medium">{formatCategory(editingCategory)}</span>
|
||||
<input
|
||||
autoFocus
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleBudgetSave(editingCategory);
|
||||
if (e.key === "Escape") {
|
||||
setEditingCategory(null);
|
||||
setEditValue("");
|
||||
}
|
||||
}}
|
||||
className="w-28 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
placeholder="Budget amount"
|
||||
{/* Monthly trend bar chart */}
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium mb-4">Monthly Spending</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={trendData} margin={{ top: 0, right: 8, bottom: 0, left: 8 }}>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: "#71717a", fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleBudgetSave(editingCategory)}
|
||||
className="px-3 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg"
|
||||
<YAxis
|
||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => `$${v}`}
|
||||
width={56}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [fmtExact(Number(value)), "Spent"]}
|
||||
contentStyle={{
|
||||
background: "#18181b",
|
||||
border: "1px solid #3f3f46",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: "#a1a1aa" }}
|
||||
cursor={{ fill: "rgba(255,255,255,0.04)" }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="spent"
|
||||
radius={[4, 4, 0, 0]}
|
||||
onClick={(data) => setSelectedMonth((data as unknown as { month: string }).month)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{trendData.map((entry) => (
|
||||
<Cell
|
||||
key={entry.month}
|
||||
fill={entry.month === selectedMonth ? "#6366f1" : "#3f3f46"}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Spending by category — horizontal bar chart */}
|
||||
{categoryChartData.length > 0 && (
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium mb-4">
|
||||
Spending by Category — {formatMonth(selectedMonth)}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||
<BarChart
|
||||
data={categoryChartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 8, bottom: 0, left: 8 }}
|
||||
>
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => `$${v}`}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="category"
|
||||
width={120}
|
||||
tick={{ fill: "#a1a1aa", fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={formatCategory}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [fmtExact(Number(value)), "Spent"]}
|
||||
contentStyle={{
|
||||
background: "#18181b",
|
||||
border: "1px solid #3f3f46",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelFormatter={(label) => formatCategory(String(label))}
|
||||
labelStyle={{ color: "#a1a1aa" }}
|
||||
cursor={{ fill: "rgba(255,255,255,0.04)" }}
|
||||
/>
|
||||
<Bar dataKey="spent" fill="#6366f1" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category breakdown table */}
|
||||
{categoryRows.length > 0 && (
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-zinc-800">
|
||||
<h3 className="text-sm font-medium">Category Breakdown — {formatMonth(selectedMonth)}</h3>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
|
||||
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Spent</th>
|
||||
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium"># Txns</th>
|
||||
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">% of Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categoryRows.map(({ category, spent, txCount }) => (
|
||||
<tr key={category} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||
<td className="px-4 py-2.5 font-medium">{formatCategory(category)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">{fmtExact(spent)}</td>
|
||||
<td className="px-4 py-2.5 text-right text-zinc-400">{txCount}</td>
|
||||
<td className="px-4 py-2.5 text-right text-zinc-400 tabular-nums">
|
||||
{totalSpent > 0 ? ((spent / totalSpent) * 100).toFixed(1) : "0.0"}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 6-month trend table */}
|
||||
{analytics && analytics.months.length > 0 && (
|
||||
{analytics.months.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-400 mb-3">6-Month Trend</h3>
|
||||
<div className="overflow-x-auto rounded-xl border border-zinc-700">
|
||||
@@ -322,9 +299,12 @@ export default function BudgetPage() {
|
||||
{analytics.months.map((m) => (
|
||||
<th
|
||||
key={m}
|
||||
className="text-right px-3 py-2 text-zinc-500 font-medium whitespace-nowrap"
|
||||
className={`text-right px-3 py-2 font-medium whitespace-nowrap cursor-pointer hover:text-zinc-300 ${
|
||||
m === selectedMonth ? "text-indigo-400" : "text-zinc-500"
|
||||
}`}
|
||||
onClick={() => setSelectedMonth(m)}
|
||||
>
|
||||
{m}
|
||||
{formatShortMonth(m)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -337,21 +317,14 @@ export default function BudgetPage() {
|
||||
</td>
|
||||
{analytics.months.map((m) => {
|
||||
const spent = row.spent[m];
|
||||
const budget = row.budget[m];
|
||||
const overBudget =
|
||||
spent !== undefined && budget !== undefined && spent > budget;
|
||||
return (
|
||||
<td
|
||||
key={m}
|
||||
className={`px-3 py-2 text-right tabular-nums ${
|
||||
spent === undefined
|
||||
? "text-zinc-700"
|
||||
: overBudget
|
||||
? "text-red-300 bg-red-950/40"
|
||||
: "text-zinc-300"
|
||||
}`}
|
||||
spent === undefined ? "text-zinc-700" : "text-zinc-300"
|
||||
} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
|
||||
>
|
||||
{spent !== undefined ? `$${Number(spent).toFixed(0)}` : "—"}
|
||||
{spent !== undefined ? fmt(spent) : "—"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
@@ -361,13 +334,14 @@ export default function BudgetPage() {
|
||||
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Total</td>
|
||||
{analytics.months.map((m) => {
|
||||
const t = analytics.totals[m];
|
||||
const over = t && t.budget > 0 && t.spent > t.budget;
|
||||
return (
|
||||
<td
|
||||
key={m}
|
||||
className={`px-3 py-2 text-right tabular-nums ${over ? "text-red-300" : ""}`}
|
||||
className={`px-3 py-2 text-right tabular-nums ${
|
||||
m === selectedMonth ? "bg-zinc-800/30 text-indigo-300" : ""
|
||||
}`}
|
||||
>
|
||||
${(t?.spent || 0).toFixed(0)}
|
||||
{fmt(t?.spent || 0)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,7 +7,7 @@ const NAV_ITEMS = [
|
||||
{ href: "/transactions", label: "Transactions", icon: "receipt" },
|
||||
{ href: "/statements", label: "Statements", icon: "file-text" },
|
||||
{ href: "/shared", label: "Shared", icon: "users" },
|
||||
{ href: "/budget", label: "Budget", icon: "bar-chart" },
|
||||
{ href: "/budget", label: "Analytics", icon: "bar-chart" },
|
||||
{ href: "/tags", label: "Tags", icon: "tag" },
|
||||
{ href: "/rules", label: "Rules", icon: "settings" },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user