From 23aa8b08d4d61645d259f17dd2fe5f842b630415 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Sep 2025 00:32:30 +0000 Subject: [PATCH] Implement comprehensive Analytics Dashboard with charts and financial insights Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com> --- config.toml | 30 ++ frontend/package-lock.json | 374 +++++++++++++++++- frontend/package.json | 1 + .../src/components/analytics/BalanceChart.tsx | 127 ++++++ .../components/analytics/MonthlyTrends.tsx | 170 ++++++++ .../src/components/analytics/StatCard.tsx | 64 +++ .../analytics/TransactionDistribution.tsx | 126 ++++++ frontend/src/lib/api.ts | 12 + frontend/src/routes/analytics.tsx | 183 ++++++++- frontend/src/types/api.ts | 13 + 10 files changed, 1092 insertions(+), 8 deletions(-) create mode 100644 config.toml create mode 100644 frontend/src/components/analytics/BalanceChart.tsx create mode 100644 frontend/src/components/analytics/MonthlyTrends.tsx create mode 100644 frontend/src/components/analytics/StatCard.tsx create mode 100644 frontend/src/components/analytics/TransactionDistribution.tsx diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..26926a3 --- /dev/null +++ b/config.toml @@ -0,0 +1,30 @@ +[gocardless] +key = "your-api-key" +secret = "your-secret-key" +url = "https://bankaccountdata.gocardless.com/api/v2" + +[database] +sqlite = true + +# Optional: Background sync scheduling +[scheduler.sync] +enabled = true +hour = 3 # 3 AM +minute = 0 +# cron = "0 3 * * *" # Alternative: use cron expression + +# Optional: Discord notifications +[notifications.discord] +webhook = "https://discord.com/api/webhooks/..." +enabled = true + +# Optional: Telegram notifications +[notifications.telegram] +token = "your-bot-token" +chat_id = 12345 +enabled = true + +# Optional: Transaction filters for notifications +[filters] +case-insensitive = ["salary", "utility"] +case-sensitive = ["SpecificStore"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 22efbec..325d424 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "postcss": "^8.5.6", "react": "^19.1.1", "react-dom": "^19.1.1", + "recharts": "^3.2.0", "tailwindcss": "^3.4.17" }, "devDependencies": { @@ -1215,6 +1216,32 @@ "node": ">=14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", + "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "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/@rolldown/pluginutils": { "version": "1.0.0-beta.34", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", @@ -1516,6 +1543,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "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/@tailwindcss/forms": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", @@ -1869,6 +1908,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "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.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "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", @@ -1887,7 +1989,7 @@ "version": "19.1.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1903,6 +2005,12 @@ "@types/react": "^19.0.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.43.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz", @@ -2729,9 +2837,130 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -2749,6 +2978,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", @@ -2863,6 +3098,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.39.10", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", + "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -3117,6 +3362,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3540,6 +3791,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "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", @@ -3567,6 +3828,15 @@ "node": ">=0.8.19" } }, + "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-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4363,6 +4633,36 @@ "react": "^19.1.1" } }, + "node_modules/react-is": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", + "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4419,6 +4719,48 @@ "node": ">=0.10.0" } }, + "node_modules/recharts": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.0.tgz", + "integrity": "sha512-fX0xCgNXo6mag9wz3oLuANR+dUQM4uIlTYBGTGq9CBRgW/8TZPzqPGYs5NTt8aENCf+i1CI8vqxT1py8L/5J2w==", + "license": "MIT", + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 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/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4428,6 +4770,12 @@ "node": ">=0.10.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/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -5093,6 +5441,28 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4580fa8..e0f88b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "postcss": "^8.5.6", "react": "^19.1.1", "react-dom": "^19.1.1", + "recharts": "^3.2.0", "tailwindcss": "^3.4.17" }, "devDependencies": { diff --git a/frontend/src/components/analytics/BalanceChart.tsx b/frontend/src/components/analytics/BalanceChart.tsx new file mode 100644 index 0000000..e140a60 --- /dev/null +++ b/frontend/src/components/analytics/BalanceChart.tsx @@ -0,0 +1,127 @@ +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import type { Balance } from "../../types/api"; + +interface BalanceChartProps { + data: Balance[]; + className?: string; +} + +interface ChartDataPoint { + date: string; + balance: number; + account_id: string; +} + +interface AggregatedDataPoint { + date: string; + [key: string]: string | number; +} + +export default function BalanceChart({ data, className }: BalanceChartProps) { + // Process balance data for the chart + const chartData = data + .filter((balance) => balance.balance_type === "closingBooked") + .map((balance) => ({ + date: new Date(balance.reference_date).toLocaleDateString(), + balance: balance.balance_amount, + account_id: balance.account_id, + })) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + + // Group by account and aggregate + const accountBalances: { [key: string]: ChartDataPoint[] } = {}; + chartData.forEach((item) => { + if (!accountBalances[item.account_id]) { + accountBalances[item.account_id] = []; + } + accountBalances[item.account_id].push(item); + }); + + // Create aggregated data points + const aggregatedData: { [key: string]: AggregatedDataPoint } = {}; + Object.entries(accountBalances).forEach(([accountId, balances]) => { + balances.forEach((balance) => { + if (!aggregatedData[balance.date]) { + aggregatedData[balance.date] = { date: balance.date }; + } + aggregatedData[balance.date][accountId] = balance.balance; + }); + }); + + const finalData = Object.values(aggregatedData).sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() + ); + + const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"]; + + if (finalData.length === 0) { + return ( +
+

+ Balance Progress +

+
+ No balance data available +
+
+ ); + } + + return ( +
+

+ Balance Progress Over Time +

+
+ + + + { + const date = new Date(value); + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + }} + /> + `€${value.toLocaleString()}`} + /> + [ + `€${value.toLocaleString()}`, + "Balance", + ]} + labelFormatter={(label) => `Date: ${label}`} + /> + + {Object.keys(accountBalances).map((accountId, index) => ( + + ))} + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/analytics/MonthlyTrends.tsx b/frontend/src/components/analytics/MonthlyTrends.tsx new file mode 100644 index 0000000..f6a87d0 --- /dev/null +++ b/frontend/src/components/analytics/MonthlyTrends.tsx @@ -0,0 +1,170 @@ +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { useQuery } from "@tanstack/react-query"; +import apiClient from "../../lib/api"; + +interface MonthlyTrendsProps { + className?: string; +} + +interface MonthlyData { + month: string; + income: number; + expenses: number; + net: number; +} + +interface TooltipProps { + active?: boolean; + payload?: Array<{ + name: string; + value: number; + color: string; + }>; + label?: string; +} + +export default function MonthlyTrends({ className }: MonthlyTrendsProps) { + // Get transactions for the last 12 months + const { data: transactions, isLoading } = useQuery({ + queryKey: ["transactions", "monthly-trends"], + queryFn: async () => { + const response = await apiClient.getTransactions({ + startDate: new Date( + Date.now() - 365 * 24 * 60 * 60 * 1000 + ).toISOString().split("T")[0], + endDate: new Date().toISOString().split("T")[0], + perPage: 1000, + }); + return response.data; + }, + }); + + // Process transactions into monthly data + const monthlyData: MonthlyData[] = []; + + if (transactions) { + const monthlyMap: { [key: string]: MonthlyData } = {}; + + transactions.forEach((transaction) => { + const date = new Date(transaction.transaction_date); + const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + + if (!monthlyMap[monthKey]) { + monthlyMap[monthKey] = { + month: date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short' + }), + income: 0, + expenses: 0, + net: 0, + }; + } + + if (transaction.transaction_value > 0) { + monthlyMap[monthKey].income += transaction.transaction_value; + } else { + monthlyMap[monthKey].expenses += Math.abs(transaction.transaction_value); + } + + monthlyMap[monthKey].net = monthlyMap[monthKey].income - monthlyMap[monthKey].expenses; + }); + + // Convert to array and sort by date + monthlyData.push( + ...Object.entries(monthlyMap) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, data]) => data) + .slice(-12) // Last 12 months + ); + } + + if (isLoading) { + return ( +
+

+ Monthly Spending Trends +

+
+
+
+
+ ); + } + + if (monthlyData.length === 0) { + return ( +
+

+ Monthly Spending Trends +

+
+ No transaction data available +
+
+ ); + } + + const CustomTooltip = ({ active, payload, label }: TooltipProps) => { + if (active && payload && payload.length) { + return ( +
+

{label}

+ {payload.map((entry, index) => ( +

+ {entry.name}: €{Math.abs(entry.value).toLocaleString()} +

+ ))} +
+ ); + } + return null; + }; + + return ( +
+

+ Monthly Spending Trends (Last 12 Months) +

+
+ + + + + `€${value.toLocaleString()}`} + /> + } /> + + + + +
+
+
+
+ Income +
+
+
+ Expenses +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/analytics/StatCard.tsx b/frontend/src/components/analytics/StatCard.tsx new file mode 100644 index 0000000..a000f62 --- /dev/null +++ b/frontend/src/components/analytics/StatCard.tsx @@ -0,0 +1,64 @@ +import type { LucideIcon } from "lucide-react"; +import clsx from "clsx"; + +interface StatCardProps { + title: string; + value: string | number; + subtitle?: string; + icon: LucideIcon; + trend?: { + value: number; + isPositive: boolean; + }; + className?: string; +} + +export default function StatCard({ + title, + value, + subtitle, + icon: Icon, + trend, + className, +}: StatCardProps) { + return ( +
+
+
+ +
+
+
+
+ {title} +
+
+
+ {value} +
+ {trend && ( +
+ {trend.isPositive ? "+" : ""} + {trend.value}% +
+ )} +
+ {subtitle && ( +
{subtitle}
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/analytics/TransactionDistribution.tsx b/frontend/src/components/analytics/TransactionDistribution.tsx new file mode 100644 index 0000000..b003a38 --- /dev/null +++ b/frontend/src/components/analytics/TransactionDistribution.tsx @@ -0,0 +1,126 @@ +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Tooltip, + Legend, +} from "recharts"; +import type { Account } from "../../types/api"; + +interface TransactionDistributionProps { + accounts: Account[]; + className?: string; +} + +interface PieDataPoint { + name: string; + value: number; + color: string; +} + +interface TooltipProps { + active?: boolean; + payload?: Array<{ + payload: PieDataPoint; + }>; +} + +export default function TransactionDistribution({ + accounts, + className, +}: TransactionDistributionProps) { + // Create pie chart data from account balances + const pieData: PieDataPoint[] = accounts.map((account, index) => { + const closingBalance = account.balances.find( + (balance) => balance.balance_type === "closingBooked" + ); + + const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"]; + + return { + name: account.name || `Account ${account.id.split('-')[1]}`, + value: closingBalance?.amount || 0, + color: colors[index % colors.length], + }; + }); + + const totalBalance = pieData.reduce((sum, item) => sum + item.value, 0); + + if (pieData.length === 0 || totalBalance === 0) { + return ( +
+

+ Account Distribution +

+
+ No account data available +
+
+ ); + } + + const CustomTooltip = ({ active, payload }: TooltipProps) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + const percentage = ((data.value / totalBalance) * 100).toFixed(1); + return ( +
+

{data.name}

+

+ Balance: €{data.value.toLocaleString()} +

+

{percentage}% of total

+
+ ); + } + return null; + }; + + return ( +
+

+ Account Balance Distribution +

+
+ + + + {pieData.map((entry, index) => ( + + ))} + + } /> + ( + {value} + )} + /> + + +
+
+ {pieData.map((item, index) => ( +
+
+
+ {item.name} +
+ €{item.value.toLocaleString()} +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6ff39ad..b2319f4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -10,6 +10,7 @@ import type { NotificationServicesResponse, HealthData, AccountUpdate, + TransactionStats, } from "../types/api"; // Use VITE_API_URL for development, relative URLs for production @@ -142,6 +143,17 @@ export const apiClient = { const response = await api.get>("/health"); return response.data.data; }, + + // Analytics endpoints + getTransactionStats: async (days?: number): Promise => { + const queryParams = new URLSearchParams(); + if (days) queryParams.append("days", days.toString()); + + const response = await api.get>( + `/transactions/stats?${queryParams.toString()}` + ); + return response.data.data; + }, }; export default apiClient; diff --git a/frontend/src/routes/analytics.tsx b/frontend/src/routes/analytics.tsx index 7a43939..5c3bfb1 100644 --- a/frontend/src/routes/analytics.tsx +++ b/frontend/src/routes/analytics.tsx @@ -1,10 +1,181 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { + CreditCard, + TrendingUp, + TrendingDown, + DollarSign, + Activity, + Users, +} from "lucide-react"; +import apiClient from "../lib/api"; +import StatCard from "../components/analytics/StatCard"; +import BalanceChart from "../components/analytics/BalanceChart"; +import TransactionDistribution from "../components/analytics/TransactionDistribution"; +import MonthlyTrends from "../components/analytics/MonthlyTrends"; + +function AnalyticsDashboard() { + // Fetch analytics data + const { data: stats, isLoading: statsLoading } = useQuery({ + queryKey: ["transaction-stats"], + queryFn: () => apiClient.getTransactionStats(365), // Last year + }); + + const { data: accounts, isLoading: accountsLoading } = useQuery({ + queryKey: ["accounts"], + queryFn: () => apiClient.getAccounts(), + }); + + const { data: balances, isLoading: balancesLoading } = useQuery({ + queryKey: ["balances"], + queryFn: () => apiClient.getBalances(), + }); + + const isLoading = statsLoading || accountsLoading || balancesLoading; + + if (isLoading) { + return ( +
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+
+
+
+
+ ); + } + + const totalBalance = accounts?.reduce((sum, account) => { + const closingBalance = account.balances.find( + (balance) => balance.balance_type === "closingBooked" + ); + return sum + (closingBalance?.amount || 0); + }, 0) || 0; + + return ( +
+
+

Analytics Dashboard

+

+ Overview of your financial data and spending patterns +

+
+ + {/* Stats Cards */} +
+ + + + +
+ + {/* Additional Stats */} +
+ = 0 ? "border-green-200" : "border-red-200" + } + /> + + +
+ + {/* Charts */} +
+
+ +
+
+ +
+
+ + {/* Monthly Trends */} +
+ +
+ + {/* Summary Section */} + {stats && ( +
+

+ Period Summary ({stats.period_days} days) +

+
+
+

Booked Transactions

+

{stats.booked_transactions}

+
+
+

Pending Transactions

+

{stats.pending_transactions}

+
+
+

Transaction Ratio

+

+ {stats.total_transactions > 0 + ? `${Math.round( + (stats.booked_transactions / stats.total_transactions) * 100 + )}% booked` + : "No transactions"} +

+
+
+

Spend Rate

+

+ €{((stats.total_expenses || 0) / stats.period_days).toFixed(2)}/day +

+
+
+
+ )} +
+ ); +} export const Route = createFileRoute("/analytics")({ - component: () => ( -
-

Analytics

-

Analytics dashboard coming soon...

-
- ), + component: AnalyticsDashboard, }); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index d614f0f..0514731 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -188,3 +188,16 @@ export interface HealthData { message?: string; error?: string; } + +// Analytics data types +export interface TransactionStats { + period_days: number; + total_transactions: number; + booked_transactions: number; + pending_transactions: number; + total_income: number; + total_expenses: number; + net_change: number; + average_transaction: number; + accounts_included: number; +}