From 30a7857d1371d047fc85c8a2f43cae7fd083c506 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sun, 8 Mar 2026 17:58:33 +1100 Subject: [PATCH] feat(analytics): replace budget page with spending analytics + split-adjusted amounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package-lock.json | 397 ++++++++++++++++++- package.json | 3 +- src/app/api/analytics/monthly/route.ts | 8 +- src/app/budget/page.tsx | 508 ++++++++++++------------- src/components/sidebar.tsx | 2 +- 5 files changed, 646 insertions(+), 272 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2dd3f3e..46cad90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index fd44287..8224e59 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/analytics/monthly/route.ts b/src/app/api/analytics/monthly/route.ts index 70e4052..dfae150 100644 --- a/src/app/api/analytics/monthly/route.ts +++ b/src/app/api/analytics/monthly/route.ts @@ -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' diff --git a/src/app/budget/page.tsx b/src/app/budget/page.tsx index 7c4288a..c510325 100644 --- a/src/app/budget/page.tsx +++ b/src/app/budget/page.tsx @@ -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(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([ - ...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 ( +
+

Analytics

+

Loading...

+
+ ); } + // 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 (
- {/* Month selector */} + {/* Header + month selector */}
-

Budget

+

Analytics

- {formatMonth(selectedMonth)} + + {formatMonth(selectedMonth)} + - ))} -
- {editingCategory && !allCategories.has(editingCategory) && ( -
- {formatCategory(editingCategory)} - 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 */} +
+

Monthly Spending

+ + + - -
- )} + {trendData.map((entry) => ( + + ))} + + +
+ {/* Spending by category — horizontal bar chart */} + {categoryChartData.length > 0 && ( +
+

+ Spending by Category — {formatMonth(selectedMonth)} +

+ + + `$${v}`} + /> + + [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)" }} + /> + + + +
+ )} + + {/* Category breakdown table */} + {categoryRows.length > 0 && ( +
+
+

Category Breakdown — {formatMonth(selectedMonth)}

+
+ + + + + + + + + + + {categoryRows.map(({ category, spent, txCount }) => ( + + + + + + + ))} + +
CategorySpent# Txns% of Total
{formatCategory(category)}{fmtExact(spent)}{txCount} + {totalSpent > 0 ? ((spent / totalSpent) * 100).toFixed(1) : "0.0"}% +
+
+ )} + {/* 6-month trend table */} - {analytics && analytics.months.length > 0 && ( + {analytics.months.length > 0 && (

6-Month Trend

@@ -322,9 +299,12 @@ export default function BudgetPage() { {analytics.months.map((m) => ( setSelectedMonth(m)} > - {m} + {formatShortMonth(m)} ))} @@ -337,21 +317,14 @@ export default function BudgetPage() { {analytics.months.map((m) => { const spent = row.spent[m]; - const budget = row.budget[m]; - const overBudget = - spent !== undefined && budget !== undefined && spent > budget; return ( - {spent !== undefined ? `$${Number(spent).toFixed(0)}` : "—"} + {spent !== undefined ? fmt(spent) : "—"} ); })} @@ -361,13 +334,14 @@ export default function BudgetPage() { Total {analytics.months.map((m) => { const t = analytics.totals[m]; - const over = t && t.budget > 0 && t.spent > t.budget; return ( - ${(t?.spent || 0).toFixed(0)} + {fmt(t?.spent || 0)} ); })} diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 84b2c9f..7b6cf93 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -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" }, ];