Compare commits

..

32 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
0e645d9bae Fix MonthlyTrends date parsing and add AnalyticsTransaction interface
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
d51aa9429e Fix MonthlyTrends dynamic title, remove Period Summary, convert BalanceChart to stacked area chart
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
c8f0a103c6 fix: Resolve all CI failures - linting, typing, and test issues
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
5987a759b8 Remove redundant Analytics Dashboard header section
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
6bfbed8fb6 Fix date parsing and add time period filters to Analytics dashboard
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
b7e4ec4a1b Fix Balance Progress Over Time chart by adding historical balance endpoint
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
35b6d98e6a fix(frontend): Align balance calculation between sidebar and Analytics page
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
3e248f95a8 Address PR feedback: add TODO, remove enhanced-stats, keep stats endpoint
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
e136fc4b75 feat(analytics): Fix transaction limits and improve chart legends
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
692bee574e fix(docs): Remove test files and update gitignore
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-13 19:42:53 +01:00
copilot-swe-agent[bot]
482f16c77e feat(docs): Add configuration file setup to agent instructions
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-13 19:42:53 +01:00
copilot-swe-agent[bot]
c6ac4455f8 feat(docs): Add comprehensive copilot agent setup instructions
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-13 19:42:53 +01:00
Elisiário Couto
ac0fedd8b2 chore(ci): Bump version to 2025.9.10 2025-09-13 12:20:55 +01:00
Elisiário Couto
06cf02f43f chore(frontend): Update dependencies. 2025-09-12 18:30:17 +01:00
copilot-swe-agent[bot]
23aa8b08d4 Implement comprehensive Analytics Dashboard with charts and financial insights
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 18:30:17 +01:00
Elisiário Couto
2b69b1e27b Delete config.toml 2025-09-12 17:50:58 +01:00
copilot-swe-agent[bot]
4dec8113fe Implement mobile UI improvements with status indicators and responsive layout
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 17:50:58 +01:00
copilot-swe-agent[bot]
28534e97c0 Fix mobile UI issues in accounts page with responsive layout improvements
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 17:50:58 +01:00
copilot-swe-agent[bot]
43b6f32145 Initial analysis: Mobile UI issues identified in accounts page
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 17:50:58 +01:00
Elisiário Couto
b3eab6ae26 chore(ci): Bump version to 2025.9.9 2025-09-12 00:35:04 +01:00
copilot-swe-agent[bot]
a5d10b3539 feat: Remove config.toml file - should be created when needed
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 00:32:06 +01:00
copilot-swe-agent[bot]
1c901a9dda feat(frontend): Improve transactions table mobile UX with responsive card layout
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 00:32:06 +01:00
copilot-swe-agent[bot]
1e94333d8f feat(frontend): Improve transactions table mobile UX with responsive card layout
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 00:32:06 +01:00
copilot-swe-agent[bot]
4006dd128e fix(core): Handle permission errors gracefully in database path creation.
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 00:01:05 +01:00
copilot-swe-agent[bot]
7d9744a40e refactor(core): Integrate directory creation with database path retrieval and remove backup file.
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 00:01:05 +01:00
copilot-swe-agent[bot]
8654471042 Add tests for configurable paths and finalize implementation
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 00:01:05 +01:00
copilot-swe-agent[bot]
e9711339bd Add centralized path management and sample database generator
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 00:01:05 +01:00
Elisiário Couto
0c030efef2 chore(ci): Bump version to 2025.9.8 2025-09-11 18:50:09 +01:00
copilot-swe-agent[bot]
e4e04ea34e feat: update CI workflow to use Node.js 20 instead of 18
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-11 18:48:01 +01:00
copilot-swe-agent[bot]
f4bf549b99 fix: change branch name from develop to dev in CI workflow
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-11 18:48:01 +01:00
copilot-swe-agent[bot]
8cc4f567f8 Update README with CI/CD pipeline information
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-11 18:48:01 +01:00
copilot-swe-agent[bot]
a939b841f3 Add GitHub Actions CI workflow and enhance release workflow
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-11 18:48:01 +01:00
36 changed files with 3115 additions and 335 deletions

55
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: CI
on:
push:
branches: [ "main", "dev" ]
pull_request:
branches: [ "main", "dev" ]
jobs:
test-python:
name: Test Python
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name: Create config directory for tests
run: |
mkdir -p ~/.config/leggen
cp config.example.toml ~/.config/leggen/config.toml
- name: Run Python tests
run: uv run pytest
test-frontend:
name: Test Frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./frontend
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm install
- name: Run lint
run: npm run lint
- name: Run build
run: npm run build

View File

@@ -133,3 +133,34 @@ jobs:
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
create-github-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: [build, publish-to-pypi, push-docker-backend, push-docker-frontend]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install git-cliff
run: |
wget -qO- https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-2.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz
sudo mv git-cliff-*/git-cliff /usr/local/bin/
- name: Generate release notes
id: release_notes
run: |
echo "notes<<EOF" >> $GITHUB_OUTPUT
git-cliff --current >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
body: ${{ steps.release_notes.outputs.notes }}
draft: false
prerelease: false

2
.gitignore vendored
View File

@@ -162,3 +162,5 @@ docker-compose.dev.yml
nocodb/
sql/
leggen.db
*.db
config.toml

View File

@@ -1,5 +1,55 @@
# Agent Guidelines for Leggen
## Quick Setup for Development
### Prerequisites
- **uv** must be installed for Python dependency management (can be installed via `pip install uv`)
- **Configuration file**: Copy `config.example.toml` to `config.toml` before running any commands:
```bash
cp config.example.toml config.toml
```
### Generate Mock Database
The leggen CLI provides a command to generate a mock database for testing:
```bash
# Generate sample database with default settings (3 accounts, 50 transactions each)
uv run leggen --config config.toml generate_sample_db --database /path/to/test.db --force
# Custom configuration
uv run leggen --config config.toml generate_sample_db --database ./test-data.db --accounts 5 --transactions 100 --force
```
The command outputs instructions for setting the required environment variable to use the generated database.
### Start the API Server
1. Install uv if not already installed: `pip install uv`
2. Set the database environment variable to point to your generated mock database:
```bash
export LEGGEN_DATABASE_PATH=/path/to/your/generated/database.db
```
3. Ensure the API can find the configuration file (choose one):
```bash
# Option 1: Copy config to the expected location
mkdir -p ~/.config/leggen && cp config.toml ~/.config/leggen/config.toml
# Option 2: Set environment variable to current config file
export LEGGEN_CONFIG_FILE=./config.toml
```
4. Start the API server:
```bash
uv run leggend
```
- For development mode with auto-reload: `uv run leggend --reload`
- API will be available at `http://localhost:8000` with docs at `http://localhost:8000/docs`
### Start the Frontend
1. Navigate to the frontend directory: `cd frontend`
2. Install npm dependencies: `npm install`
3. Start the development server: `npm run dev`
- Frontend will be available at `http://localhost:3000`
- The frontend is configured to connect to the API at `http://localhost:8000/api/v1`
## Build/Lint/Test Commands
### Frontend (React/TypeScript)
@@ -37,6 +87,20 @@
### General
- **Formatting**: ruff for Python, ESLint for TypeScript
- **Commits**: Use conventional commits, run pre-commit hooks before pushing
- **Commits**: Use conventional commits with optional scopes, run pre-commit hooks before pushing
- Format: `type(scope): Description starting with uppercase and ending with period.`
- Scopes: `cli`, `api`, `frontend` (optional)
- Types: `feat`, `fix`, `refactor` (avoid too many different types)
- Examples:
- `feat(frontend): Add support for S3 backups.`
- `fix(api): Resolve authentication timeout issues.`
- `refactor(cli): Improve error handling for missing config.`
- Avoid including specific numbers, counts, or data-dependent information that may become outdated
- **Security**: Never log sensitive data, use environment variables for secrets
## Contributing Guidelines
This repository follows conventional changelog practices. Refer to `CONTRIBUTING.md` for detailed contribution guidelines including:
- Commit message format and scoping
- Release process using `scripts/release.sh`
- Pre-commit hooks setup with `pre-commit install`

View File

@@ -1,4 +1,86 @@
## 2025.9.10 (2025/09/13)
### Miscellaneous Tasks
- **frontend:** Update dependencies. ([06cf02f4](https://github.com/elisiariocouto/leggen/commit/06cf02f43ff72e4e01692e3a94a06be48d9acb1f))
## 2025.9.10 (2025/09/13)
### Miscellaneous Tasks
- **frontend:** Update dependencies. ([06cf02f4](https://github.com/elisiariocouto/leggen/commit/06cf02f43ff72e4e01692e3a94a06be48d9acb1f))
## 2025.9.9 (2025/09/11)
### Bug Fixes
- **core:** Handle permission errors gracefully in database path creation. ([4006dd12](https://github.com/elisiariocouto/leggen/commit/4006dd128e0896b338cb93fad60a1eca90c1873d))
### Features
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1e94333d](https://github.com/elisiariocouto/leggen/commit/1e94333d8f0275542ae7fd6e49fb8b7f03ad3d11))
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1c901a9d](https://github.com/elisiariocouto/leggen/commit/1c901a9ddab0f6515dce56df8cce74518805a6bb))
- Remove config.toml file - should be created when needed ([a5d10b35](https://github.com/elisiariocouto/leggen/commit/a5d10b3539e7cfc649b0fee05b12c4a03681e135))
### Refactor
- **core:** Integrate directory creation with database path retrieval and remove backup file. ([7d9744a4](https://github.com/elisiariocouto/leggen/commit/7d9744a40e7898e5bbe52e2e9f54317aa5c1cdd6))
## 2025.9.9 (2025/09/11)
### Bug Fixes
- **core:** Handle permission errors gracefully in database path creation. ([4006dd12](https://github.com/elisiariocouto/leggen/commit/4006dd128e0896b338cb93fad60a1eca90c1873d))
### Features
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1e94333d](https://github.com/elisiariocouto/leggen/commit/1e94333d8f0275542ae7fd6e49fb8b7f03ad3d11))
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1c901a9d](https://github.com/elisiariocouto/leggen/commit/1c901a9ddab0f6515dce56df8cce74518805a6bb))
- Remove config.toml file - should be created when needed ([a5d10b35](https://github.com/elisiariocouto/leggen/commit/a5d10b3539e7cfc649b0fee05b12c4a03681e135))
### Refactor
- **core:** Integrate directory creation with database path retrieval and remove backup file. ([7d9744a4](https://github.com/elisiariocouto/leggen/commit/7d9744a40e7898e5bbe52e2e9f54317aa5c1cdd6))
## 2025.9.8 (2025/09/11)
### Bug Fixes
- Change branch name from develop to dev in CI workflow ([f4bf549b](https://github.com/elisiariocouto/leggen/commit/f4bf549b99197d70104abf5731ab1ccb67cc9a69))
### Features
- Update CI workflow to use Node.js 20 instead of 18 ([e4e04ea3](https://github.com/elisiariocouto/leggen/commit/e4e04ea34ea568c08292562243b6e6c08234d918))
## 2025.9.8 (2025/09/11)
### Bug Fixes
- Change branch name from develop to dev in CI workflow ([f4bf549b](https://github.com/elisiariocouto/leggen/commit/f4bf549b99197d70104abf5731ab1ccb67cc9a69))
### Features
- Update CI workflow to use Node.js 20 instead of 18 ([e4e04ea3](https://github.com/elisiariocouto/leggen/commit/e4e04ea34ea568c08292562243b6e6c08234d918))
## 2025.9.7 (2025/09/11)
### Bug Fixes

View File

@@ -357,6 +357,10 @@ tests/ # Test suite
3. Make your changes with tests
4. Submit a pull request
The repository uses GitHub Actions for CI/CD:
- **CI**: Runs Python tests (`uv run pytest`) and frontend linting/build on every push
- **Release**: Creates GitHub releases with changelog when tags are pushed
## ⚠️ Notes
- This project is in active development
- GoCardless API rate limits apply

View File

@@ -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": {
@@ -1161,9 +1162,9 @@
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.30",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -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",
@@ -1542,9 +1581,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.87.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.87.1.tgz",
"integrity": "sha512-HOFHVvhOCprrWvtccSzc7+RNqpnLlZ5R6lTmngb8aq7b4rc2/jDT0w+vLdQ4lD9bNtQ+/A4GsFXy030Gk4ollA==",
"version": "5.87.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.87.4.tgz",
"integrity": "sha512-uNsg6zMxraEPDVO2Bn+F3/ctHi+Zsk+MMpcN8h6P7ozqD088F6mFY5TfGM7zuyIrL7HKpDyu6QHfLWiDxh3cuw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -1552,12 +1591,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.87.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.87.1.tgz",
"integrity": "sha512-YKauf8jfMowgAqcxj96AHs+Ux3m3bWT1oSVKamaRPXSnW2HqSznnTCEkAVqctF1e/W9R/mPcyzzINIgpOH94qg==",
"version": "5.87.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.87.4.tgz",
"integrity": "sha512-T5GT/1ZaNsUXf5I3RhcYuT17I4CPlbZgyLxc/ZGv7ciS6esytlbjb3DgUFO6c8JWYMDpdjSWInyGZUErgzqhcA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.87.1"
"@tanstack/query-core": "5.87.4"
},
"funding": {
"type": "github",
@@ -1568,14 +1607,14 @@
}
},
"node_modules/@tanstack/react-router": {
"version": "1.131.36",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.131.36.tgz",
"integrity": "sha512-9tglm3Rf9qkANBIyYLbGlOjNj7GDBr0jOEOaADfwiGV3Ua3P562MGn7nHUOrfRfA6u2MCg0EKJ+LH7AeWxAqkg==",
"version": "1.131.41",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.131.41.tgz",
"integrity": "sha512-QEbTYpAosiD8e4qEZRr9aJipGSb8pQc+pfZwK6NCD2Tcxwu2oF6MVtwv0bIDLRpZP0VJMBpxXlTRISUDNMNqIA==",
"license": "MIT",
"dependencies": {
"@tanstack/history": "1.131.2",
"@tanstack/react-store": "^0.7.0",
"@tanstack/router-core": "1.131.36",
"@tanstack/router-core": "1.131.41",
"isbot": "^5.1.22",
"tiny-invariant": "^1.3.3",
"tiny-warning": "^1.0.3"
@@ -1631,12 +1670,12 @@
}
},
"node_modules/@tanstack/router-cli": {
"version": "1.131.36",
"resolved": "https://registry.npmjs.org/@tanstack/router-cli/-/router-cli-1.131.36.tgz",
"integrity": "sha512-rr5NLHJhREqdPqDgfeHc63jneYoOuQLyh6oL8dH1kVaEQ/1ESPc/MYDriIyj4S5ZnMS0RJPj1dfgRUd0t7mxGA==",
"version": "1.131.41",
"resolved": "https://registry.npmjs.org/@tanstack/router-cli/-/router-cli-1.131.41.tgz",
"integrity": "sha512-EpLnnCwwCd94HRCWHoa1GZGtIWIffx4rPBb6gbWm4cvyEIGV2Gq+27vL2OEw819/elxyBQmG2RrPB8+7dfVACw==",
"license": "MIT",
"dependencies": {
"@tanstack/router-generator": "1.131.36",
"@tanstack/router-generator": "1.131.41",
"chokidar": "^3.6.0",
"yargs": "^17.7.2"
},
@@ -1652,9 +1691,9 @@
}
},
"node_modules/@tanstack/router-core": {
"version": "1.131.36",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.131.36.tgz",
"integrity": "sha512-faGrKwrJBjJDxbcyeaOXgQcyccmzIGkwk+tnFeJuMTnH5OMfArykYnTZ9BxIrlOY2Mori9DXmYKMlig6mVqmGA==",
"version": "1.131.41",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.131.41.tgz",
"integrity": "sha512-VoLly00DWM0abKuVPRm8wiwGtRBHOKs6K896fy48Q/KYoDVLs8kRCRjFGS7rGnYC2FIkmmvHqYRqNg7jgCx2yg==",
"license": "MIT",
"dependencies": {
"@tanstack/history": "1.131.2",
@@ -1674,12 +1713,12 @@
}
},
"node_modules/@tanstack/router-generator": {
"version": "1.131.36",
"resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.131.36.tgz",
"integrity": "sha512-Rl1Q2DFcAFXaYSvHQwO+HKmp5zSBz8D3qZl+fJ0a0w4/2I+Km1xwjzDwBUkFVNJtTUor40uU76SYJzV0/9s1tw==",
"version": "1.131.41",
"resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.131.41.tgz",
"integrity": "sha512-HsDkBU1u/KvHrzn76v/9oeyMFuxvVlE3dfIu4fldZbPy/i903DWBwODIDGe6fVUsYtzPPrRvNtbjV18HVz5GCA==",
"license": "MIT",
"dependencies": {
"@tanstack/router-core": "1.131.36",
"@tanstack/router-core": "1.131.41",
"@tanstack/router-utils": "1.131.2",
"@tanstack/virtual-file-routes": "1.131.2",
"prettier": "^3.5.0",
@@ -1697,9 +1736,9 @@
}
},
"node_modules/@tanstack/router-plugin": {
"version": "1.131.36",
"resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.131.36.tgz",
"integrity": "sha512-EU/NopEkQw3AyjZvB33r4uIfUtbU64rbdJDCgGfumv1wpi/B4lJTO9W6iiUsoIsi1mtlNQKbFKNIbx+VyGh19Q==",
"version": "1.131.41",
"resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.131.41.tgz",
"integrity": "sha512-MENVYQwvhKFIPZ/YO/CGCwbh3Ba3TRvUYZ2y2KiU6aa1CWao4KHDRsungzv34AbbUBSmzbc8mKVeqd+G+E9cDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1709,8 +1748,8 @@
"@babel/template": "^7.27.2",
"@babel/traverse": "^7.27.7",
"@babel/types": "^7.27.7",
"@tanstack/router-core": "1.131.36",
"@tanstack/router-generator": "1.131.36",
"@tanstack/router-core": "1.131.41",
"@tanstack/router-generator": "1.131.41",
"@tanstack/router-utils": "1.131.2",
"@tanstack/virtual-file-routes": "1.131.2",
"babel-dead-code-elimination": "^1.0.10",
@@ -1727,7 +1766,7 @@
},
"peerDependencies": {
"@rsbuild/core": ">=1.0.2",
"@tanstack/react-router": "^1.131.36",
"@tanstack/react-router": "^1.131.41",
"vite": ">=5.0.0 || >=6.0.0",
"vite-plugin-solid": "^2.11.2",
"webpack": ">=5.92.0"
@@ -1772,13 +1811,13 @@
}
},
"node_modules/@tanstack/router-vite-plugin": {
"version": "1.131.36",
"resolved": "https://registry.npmjs.org/@tanstack/router-vite-plugin/-/router-vite-plugin-1.131.36.tgz",
"integrity": "sha512-+mS+O7tcyzMfaFGjtnnec3rMfyAeLNDS7VYO+XGXaJ+sJzT15S4yAFyXxThripwNeTdKHZQ5Iz6nCRA42RmRkA==",
"version": "1.131.41",
"resolved": "https://registry.npmjs.org/@tanstack/router-vite-plugin/-/router-vite-plugin-1.131.41.tgz",
"integrity": "sha512-UNMLW5BsueJX77lAWwddWGKTDElXS23XfpvaEnxpAPS8rnu+7HEpV4bWciN5VruuTZZM5plP6bXAGec+Bi51Hw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/router-plugin": "1.131.36"
"@tanstack/router-plugin": "1.131.41"
},
"engines": {
"node": ">=12"
@@ -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",
@@ -2359,9 +2467,9 @@
}
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz",
"integrity": "sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -2388,6 +2496,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.2.tgz",
"integrity": "sha512-NvcIedLxrs9llVpX7wI+Jz4Hn9vJQkCPKrTaHIE0sW/Rj1iq6Fzby4NbyTZjQJNoypBXNaG7tEHkTgONZpwgxQ==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2424,9 +2541,9 @@
}
},
"node_modules/browserslist": {
"version": "4.25.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz",
"integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==",
"version": "4.26.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.0.tgz",
"integrity": "sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==",
"funding": [
{
"type": "opencollective",
@@ -2443,9 +2560,10 @@
],
"license": "MIT",
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
"node-releases": "^2.0.19",
"baseline-browser-mapping": "^2.8.2",
"caniuse-lite": "^1.0.30001741",
"electron-to-chromium": "^1.5.218",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3"
},
"bin": {
@@ -2548,18 +2666,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -2729,9 +2835,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 +2976,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",
@@ -2807,9 +3040,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.215",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz",
"integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==",
"version": "1.5.218",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.218.tgz",
"integrity": "sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -2863,6 +3096,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",
@@ -3040,6 +3283,19 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -3117,6 +3373,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",
@@ -3140,18 +3402,6 @@
"node": ">=8.6.0"
}
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -3414,15 +3664,15 @@
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
"is-glob": "^4.0.1"
},
"engines": {
"node": ">=10.13.0"
"node": ">= 6"
}
},
"node_modules/glob/node_modules/brace-expansion": {
@@ -3450,9 +3700,9 @@
}
},
"node_modules/globals": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz",
"integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==",
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
"integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3540,6 +3790,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 +3827,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",
@@ -3945,9 +4214,9 @@
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz",
"integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==",
"version": "2.0.21",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz",
"integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==",
"license": "MIT"
},
"node_modules/normalize-path": {
@@ -4363,6 +4632,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 +4718,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 +4769,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",
@@ -4821,6 +5168,18 @@
"node": ">=14.0.0"
}
},
"node_modules/tailwindcss/node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -5093,6 +5452,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",

View File

@@ -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": {

View File

@@ -16,6 +16,40 @@ import { formatCurrency, formatDate } from "../lib/utils";
import LoadingSpinner from "./LoadingSpinner";
import type { Account, Balance } from "../types/api";
// Helper function to get status indicator color and styles
const getStatusIndicator = (status: string) => {
const statusLower = status.toLowerCase();
switch (statusLower) {
case 'ready':
return {
color: 'bg-green-500',
tooltip: 'Ready',
};
case 'pending':
return {
color: 'bg-yellow-500',
tooltip: 'Pending',
};
case 'error':
case 'failed':
return {
color: 'bg-red-500',
tooltip: 'Error',
};
case 'inactive':
return {
color: 'bg-gray-500',
tooltip: 'Inactive',
};
default:
return {
color: 'bg-blue-500',
tooltip: status,
};
}
};
export default function AccountsOverview() {
const {
data: accounts,
@@ -201,14 +235,15 @@ export default function AccountsOverview() {
return (
<div
key={account.id}
className="p-6 hover:bg-gray-50 transition-colors"
className="p-4 sm:p-6 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-3 bg-gray-100 rounded-full">
<Building2 className="h-6 w-6 text-gray-600" />
{/* Mobile layout - stack vertically */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<div className="flex-shrink-0 p-2 sm:p-3 bg-gray-100 rounded-full">
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-gray-600" />
</div>
<div className="flex-1">
<div className="flex-1 min-w-0">
{editingAccountId === account.id ? (
<div className="space-y-2">
<div className="flex items-center space-x-2">
@@ -216,7 +251,7 @@ export default function AccountsOverview() {
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="flex-1 px-3 py-1 text-lg font-medium border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Account name"
name="search"
autoComplete="off"
@@ -245,29 +280,29 @@ export default function AccountsOverview() {
<X className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-gray-600">
{account.institution_id} {account.status}
<p className="text-sm text-gray-600 truncate">
{account.institution_id}
</p>
</div>
) : (
<div>
<div className="flex items-center space-x-2">
<h4 className="text-lg font-medium text-gray-900">
<div className="flex items-center space-x-2 min-w-0">
<h4 className="text-base sm:text-lg font-medium text-gray-900 truncate">
{account.name || "Unnamed Account"}
</h4>
<button
onClick={() => handleEditStart(account)}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 transition-colors"
title="Edit account name"
>
<Edit2 className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-gray-600">
{account.institution_id} {account.status}
<p className="text-sm text-gray-600 truncate">
{account.institution_id}
</p>
{account.iban && (
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-gray-500 mt-1 font-mono break-all sm:break-normal">
IBAN: {account.iban}
</p>
)}
@@ -276,25 +311,45 @@ export default function AccountsOverview() {
</div>
</div>
<div className="text-right">
<div className="flex items-center space-x-2">
{/* Balance and date section */}
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
{/* Mobile: date/status on left, balance on right */}
{/* Desktop: balance on top, date/status on bottom */}
{/* Date and status indicator - left on mobile, bottom on desktop */}
<div className="flex items-center space-x-2 order-1 sm:order-2">
<div
className={`w-3 h-3 rounded-full ${getStatusIndicator(account.status).color} relative group cursor-help`}
role="img"
aria-label={`Account status: ${getStatusIndicator(account.status).tooltip}`}
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10">
{getStatusIndicator(account.status).tooltip}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
</div>
</div>
<p className="text-xs sm:text-sm text-gray-500 whitespace-nowrap">
Updated{" "}
{formatDate(account.last_accessed || account.created)}
</p>
</div>
{/* Balance - right on mobile, top on desktop */}
<div className="flex items-center space-x-2 order-2 sm:order-1">
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<p
className={`text-lg font-semibold ${
className={`text-base sm:text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
</p>
</div>
<p className="text-sm text-gray-500">
Updated{" "}
{formatDate(account.last_accessed || account.created)}
</p>
</div>
</div>
</div>

View File

@@ -412,9 +412,9 @@ export default function TransactionsTable() {
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Search */}
<div>
<div className="sm:col-span-2 lg:col-span-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Search
</label>
@@ -448,8 +448,7 @@ export default function TransactionsTable() {
<option value="">All accounts</option>
{accounts?.map((account) => (
<option key={account.id} value={account.id}>
{account.name || "Unnamed Account"} (
{account.institution_id})
{account.name || "Unnamed Account"} ({account.institution_id})
</option>
))}
</select>
@@ -489,7 +488,7 @@ export default function TransactionsTable() {
</div>
{/* Amount Range Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Min Amount
@@ -547,94 +546,206 @@ export default function TransactionsTable() {
</div>
</div>
{/* Table */}
{/* Responsive Table/Cards */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center space-x-1">
<span>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</span>
{header.column.getCanSort() && (
<div className="flex flex-col">
<ChevronUp
className={`h-3 w-3 ${
header.column.getIsSorted() === "asc"
? "text-blue-600"
: "text-gray-400"
}`}
/>
<ChevronDown
className={`h-3 w-3 -mt-1 ${
header.column.getIsSorted() === "desc"
? "text-blue-600"
: "text-gray-400"
}`}
/>
</div>
)}
</div>
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="px-6 py-12 text-center"
>
<div className="text-gray-400 mb-4">
<TrendingUp className="h-12 w-12 mx-auto" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No transactions found
</h3>
<p className="text-gray-600">
{hasActiveFilters
? "Try adjusting your filters to see more results."
: "No transactions are available for the selected criteria."}
</p>
</td>
</tr>
) : (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
{/* Desktop Table View (hidden on mobile) */}
<div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center space-x-1">
<span>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</span>
{header.column.getCanSort() && (
<div className="flex flex-col">
<ChevronUp
className={`h-3 w-3 ${
header.column.getIsSorted() === "asc"
? "text-blue-600"
: "text-gray-400"
}`}
/>
<ChevronDown
className={`h-3 w-3 -mt-1 ${
header.column.getIsSorted() === "desc"
? "text-blue-600"
: "text-gray-400"
}`}
/>
</div>
)}
</div>
</th>
))}
</tr>
))
)}
</tbody>
</table>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="px-6 py-12 text-center"
>
<div className="text-gray-400 mb-4">
<TrendingUp className="h-12 w-12 mx-auto" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No transactions found
</h3>
<p className="text-gray-600">
{hasActiveFilters
? "Try adjusting your filters to see more results."
: "No transactions are available for the selected criteria."}
</p>
</td>
</tr>
) : (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Mobile Card View (visible only on mobile) */}
<div className="md:hidden">
{table.getRowModel().rows.length === 0 ? (
<div className="px-6 py-12 text-center">
<div className="text-gray-400 mb-4">
<TrendingUp className="h-12 w-12 mx-auto" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No transactions found
</h3>
<p className="text-gray-600">
{hasActiveFilters
? "Try adjusting your filters to see more results."
: "No transactions are available for the selected criteria."}
</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{table.getRowModel().rows.map((row) => {
const transaction = row.original;
const account = accounts?.find(
(acc) => acc.id === transaction.account_id,
);
const isPositive = transaction.transaction_value > 0;
return (
<div
key={row.id}
className="p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-start space-x-3">
<div
className={`p-2 rounded-full flex-shrink-0 ${
isPositive ? "bg-green-100" : "bg-red-100"
}`}
>
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-600" />
) : (
<TrendingDown className="h-4 w-4 text-red-600" />
)}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 break-words">
{transaction.description}
</h4>
<div className="text-xs text-gray-500 space-y-1 mt-1">
{account && (
<p className="break-words">
{account.name || "Unnamed Account"} {" "}
{account.institution_id}
</p>
)}
{(transaction.creditor_name || transaction.debtor_name) && (
<p className="break-words">
{isPositive ? "From: " : "To: "}
{transaction.creditor_name || transaction.debtor_name}
</p>
)}
{transaction.reference && (
<p className="break-words">Ref: {transaction.reference}</p>
)}
<p className="text-gray-400">
{transaction.transaction_date
? formatDate(transaction.transaction_date)
: "No date"}
{transaction.booking_date &&
transaction.booking_date !== transaction.transaction_date && (
<span className="ml-2">
(Booked: {formatDate(transaction.booking_date)})
</span>
)}
</p>
</div>
</div>
</div>
</div>
<div className="text-right ml-3 flex-shrink-0">
<p
className={`text-lg font-semibold mb-1 ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{isPositive ? "+" : ""}
{formatCurrency(
transaction.transaction_value,
transaction.transaction_currency,
)}
</p>
<button
onClick={() => handleViewRaw(transaction)}
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
title="View raw transaction data"
>
<Eye className="h-3 w-3 mr-1" />
Raw
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Pagination */}
{pagination && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<div className="bg-white px-4 py-3 flex flex-col sm:flex-row items-center justify-between border-t border-gray-200 space-y-3 sm:space-y-0">
{/* Mobile pagination controls */}
<div className="flex justify-between w-full sm:hidden">
<div className="flex space-x-2">
<button
onClick={() => setCurrentPage(1)}
@@ -670,7 +781,22 @@ export default function TransactionsTable() {
</button>
</div>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
{/* Mobile pagination info */}
<div className="text-center w-full sm:hidden">
<p className="text-sm text-gray-700">
Page <span className="font-medium">{pagination.page}</span> of{" "}
<span className="font-medium">{pagination.total_pages}</span>
<br />
<span className="text-xs text-gray-500">
Showing {(pagination.page - 1) * pagination.per_page + 1}-
{Math.min(pagination.page * pagination.per_page, pagination.total)} of {pagination.total}
</span>
</p>
</div>
{/* Desktop pagination */}
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div className="flex items-center space-x-2">
<p className="text-sm text-gray-700">
Showing{" "}

View File

@@ -0,0 +1,157 @@
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import type { Balance, Account } from "../../types/api";
interface BalanceChartProps {
data: Balance[];
accounts: Account[];
className?: string;
}
interface ChartDataPoint {
date: string;
balance: number;
account_id: string;
}
interface AggregatedDataPoint {
date: string;
[key: string]: string | number;
}
export default function BalanceChart({ data, accounts, className }: BalanceChartProps) {
// Create a lookup map for account info
const accountMap = accounts.reduce((map, account) => {
map[account.id] = account;
return map;
}, {} as Record<string, Account>);
// Helper function to get bank name from institution_id
const getBankName = (institutionId: string): string => {
const bankMapping: Record<string, string> = {
'REVOLUT_REVOLT21': 'Revolut',
'NUBANK_NUPBBR25': 'Nu Pagamentos',
'BANCOBPI_BBPIPTPL': 'Banco BPI',
// Add more mappings as needed
};
return bankMapping[institutionId] || institutionId.split('_')[0];
};
// Helper function to create display name for account
const getAccountDisplayName = (accountId: string): string => {
const account = accountMap[accountId];
if (account) {
const bankName = getBankName(account.institution_id);
const accountName = account.name || `Account ${accountId.split('-')[1]}`;
return `${bankName} - ${accountName}`;
}
return `Account ${accountId.split('-')[1]}`;
};
// Process balance data for the chart
const chartData = data
.filter((balance) => balance.balance_type === "closingBooked")
.map((balance) => ({
date: new Date(balance.reference_date).toLocaleDateString('en-GB'), // DD/MM/YYYY format
balance: balance.balance_amount,
account_id: balance.account_id,
}))
.sort((a, b) => new Date(a.date.split('/').reverse().join('/')).getTime() - new Date(b.date.split('/').reverse().join('/')).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.split('/').reverse().join('/')).getTime() - new Date(b.date.split('/').reverse().join('/')).getTime()
);
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
if (finalData.length === 0) {
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Balance Progress
</h3>
<div className="h-80 flex items-center justify-center text-gray-500">
No balance data available
</div>
</div>
);
}
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Balance Progress Over Time
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={finalData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
tickFormatter={(value) => {
// Convert DD/MM/YYYY back to a proper date for formatting
const [day, month, year] = value.split('/');
const date = new Date(year, month - 1, day);
return date.toLocaleDateString('en-GB', {
month: "short",
day: "numeric",
});
}}
/>
<YAxis
tick={{ fontSize: 12 }}
tickFormatter={(value) => `${value.toLocaleString()}`}
/>
<Tooltip
formatter={(value: number, name: string) => [
`${value.toLocaleString()}`,
getAccountDisplayName(name),
]}
labelFormatter={(label) => `Date: ${label}`}
/>
<Legend />
{Object.keys(accountBalances).map((accountId, index) => (
<Area
key={accountId}
type="monotone"
dataKey={accountId}
stackId="1"
fill={colors[index % colors.length]}
stroke={colors[index % colors.length]}
name={getAccountDisplayName(accountId)}
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@@ -0,0 +1,182 @@
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;
days?: number;
}
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, days = 365 }: MonthlyTrendsProps) {
// Get transactions for the specified period using analytics endpoint
const { data: transactions, isLoading } = useQuery({
queryKey: ["transactions", "monthly-trends", days],
queryFn: async () => {
return await apiClient.getTransactionsForAnalytics(days);
},
});
// Process transactions into monthly data
const monthlyData: MonthlyData[] = [];
if (transactions) {
const monthlyMap: { [key: string]: MonthlyData } = {};
transactions.forEach((transaction) => {
const date = new Date(transaction.date);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
if (!monthlyMap[monthKey]) {
monthlyMap[monthKey] = {
month: date.toLocaleDateString('en-GB', {
year: 'numeric',
month: 'short'
}),
income: 0,
expenses: 0,
net: 0,
};
}
if (transaction.amount > 0) {
monthlyMap[monthKey].income += transaction.amount;
} else {
monthlyMap[monthKey].expenses += Math.abs(transaction.amount);
}
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)
);
}
// Calculate number of months to display based on days filter
const getMonthsToDisplay = (days: number): number => {
if (days <= 30) return 1;
if (days <= 180) return 6;
if (days <= 365) return 12;
return Math.ceil(days / 30);
};
const monthsToDisplay = getMonthsToDisplay(days);
const displayData = monthlyData.slice(-monthsToDisplay);
if (isLoading) {
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Monthly Spending Trends
</h3>
<div className="h-80 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</div>
);
}
if (displayData.length === 0) {
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Monthly Spending Trends
</h3>
<div className="h-80 flex items-center justify-center text-gray-500">
No transaction data available
</div>
</div>
);
}
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
if (active && payload && payload.length) {
return (
<div className="bg-white p-3 border rounded shadow-lg">
<p className="font-medium">{label}</p>
{payload.map((entry, index) => (
<p key={index} style={{ color: entry.color }}>
{entry.name}: {Math.abs(entry.value).toLocaleString()}
</p>
))}
</div>
);
}
return null;
};
// Generate dynamic title based on time period
const getTitle = (days: number): string => {
if (days <= 30) return "Monthly Spending Trends (Last 30 Days)";
if (days <= 180) return "Monthly Spending Trends (Last 6 Months)";
if (days <= 365) return "Monthly Spending Trends (Last 12 Months)";
return `Monthly Spending Trends (Last ${Math.ceil(days / 30)} Months)`;
};
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
{getTitle(days)}
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={displayData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="month"
tick={{ fontSize: 12 }}
angle={-45}
textAnchor="end"
height={60}
/>
<YAxis
tick={{ fontSize: 12 }}
tickFormatter={(value) => `${value.toLocaleString()}`}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="income" fill="#10B981" name="Income" />
<Bar dataKey="expenses" fill="#EF4444" name="Expenses" />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-4 flex justify-center space-x-6 text-sm">
<div className="flex items-center">
<div className="w-3 h-3 bg-green-500 rounded mr-2" />
<span>Income</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-red-500 rounded mr-2" />
<span>Expenses</span>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div
className={clsx(
"bg-white rounded-lg shadow p-6 border border-gray-200",
className
)}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<Icon className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
{title}
</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">
{value}
</div>
{trend && (
<div
className={clsx(
"ml-2 flex items-baseline text-sm font-semibold",
trend.isPositive ? "text-green-600" : "text-red-600"
)}
>
{trend.isPositive ? "+" : ""}
{trend.value}%
</div>
)}
</dd>
{subtitle && (
<dd className="text-sm text-gray-600 mt-1">{subtitle}</dd>
)}
</dl>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { Calendar } from "lucide-react";
import type { TimePeriod } from "../../lib/timePeriods";
import { TIME_PERIODS } from "../../lib/timePeriods";
interface TimePeriodFilterProps {
selectedPeriod: TimePeriod;
onPeriodChange: (period: TimePeriod) => void;
className?: string;
}
export default function TimePeriodFilter({
selectedPeriod,
onPeriodChange,
className = "",
}: TimePeriodFilterProps) {
return (
<div className={`flex items-center gap-4 ${className}`}>
<div className="flex items-center gap-2 text-gray-700">
<Calendar size={20} />
<span className="font-medium">Time Period:</span>
</div>
<div className="flex gap-2">
{TIME_PERIODS.map((period) => (
<button
key={period.value}
onClick={() => onPeriodChange(period)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
selectedPeriod.value === period.value
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{period.label}
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
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) {
// Helper function to get bank name from institution_id
const getBankName = (institutionId: string): string => {
const bankMapping: Record<string, string> = {
'REVOLUT_REVOLT21': 'Revolut',
'NUBANK_NUPBBR25': 'Nu Pagamentos',
'BANCOBPI_BBPIPTPL': 'Banco BPI',
// TODO: Add more bank mappings as needed
};
return bankMapping[institutionId] || institutionId.split('_')[0];
};
// Helper function to create display name for account
const getAccountDisplayName = (account: Account): string => {
const bankName = getBankName(account.institution_id);
const accountName = account.name || `Account ${account.id.split('-')[1]}`;
return `${bankName} - ${accountName}`;
};
// Create pie chart data from account balances
const pieData: PieDataPoint[] = accounts.map((account, index) => {
const primaryBalance = account.balances?.[0]?.amount || 0;
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
return {
name: getAccountDisplayName(account),
value: primaryBalance,
color: colors[index % colors.length],
};
});
const totalBalance = pieData.reduce((sum, item) => sum + item.value, 0);
if (pieData.length === 0 || totalBalance === 0) {
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Account Distribution
</h3>
<div className="h-80 flex items-center justify-center text-gray-500">
No account data available
</div>
</div>
);
}
const CustomTooltip = ({ active, payload }: TooltipProps) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const percentage = ((data.value / totalBalance) * 100).toFixed(1);
return (
<div className="bg-white p-3 border rounded shadow-lg">
<p className="font-medium">{data.name}</p>
<p className="text-blue-600">
Balance: {data.value.toLocaleString()}
</p>
<p className="text-gray-600">{percentage}% of total</p>
</div>
);
}
return null;
};
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Account Balance Distribution
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
outerRadius={100}
innerRadius={40}
paddingAngle={2}
dataKey="value"
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend
formatter={(value, entry: { color?: string }) => (
<span style={{ color: entry.color }}>{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="mt-4 grid grid-cols-1 gap-2">
{pieData.map((item, index) => (
<div key={index} className="flex items-center justify-between text-sm">
<div className="flex items-center">
<div
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: item.color }}
/>
<span className="text-gray-700">{item.name}</span>
</div>
<span className="font-medium">{item.value.toLocaleString()}</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import axios from "axios";
import type {
Account,
Transaction,
AnalyticsTransaction,
Balance,
ApiResponse,
NotificationSettings,
@@ -10,6 +11,7 @@ import type {
NotificationServicesResponse,
HealthData,
AccountUpdate,
TransactionStats,
} from "../types/api";
// Use VITE_API_URL for development, relative URLs for production
@@ -53,6 +55,18 @@ export const apiClient = {
return response.data.data;
},
// Get historical balances for balance progression chart
getHistoricalBalances: async (days?: number, accountId?: string): Promise<Balance[]> => {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
if (accountId) queryParams.append("account_id", accountId);
const response = await api.get<ApiResponse<Balance[]>>(
`/balances/history?${queryParams.toString()}`
);
return response.data.data;
},
// Get balances for specific account
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
const response = await api.get<ApiResponse<Balance[]>>(
@@ -142,6 +156,28 @@ export const apiClient = {
const response = await api.get<ApiResponse<HealthData>>("/health");
return response.data.data;
},
// Analytics endpoints
getTransactionStats: async (days?: number): Promise<TransactionStats> => {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<TransactionStats>>(
`/transactions/stats?${queryParams.toString()}`
);
return response.data.data;
},
// Get all transactions for analytics (no pagination)
getTransactionsForAnalytics: async (days?: number): Promise<AnalyticsTransaction[]> => {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<AnalyticsTransaction[]>>(
`/transactions/analytics?${queryParams.toString()}`
);
return response.data.data;
},
};
export default apiClient;

View File

@@ -0,0 +1,19 @@
export type TimePeriod = {
label: string;
days: number;
value: string;
};
function getDaysFromYearStart(): number {
const now = new Date();
const yearStart = new Date(now.getFullYear(), 0, 1);
const diffTime = now.getTime() - yearStart.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
export const TIME_PERIODS: TimePeriod[] = [
{ label: "Last 30 days", days: 30, value: "30d" },
{ label: "Last 6 months", days: 180, value: "6m" },
{ label: "Year to Date", days: getDaysFromYearStart(), value: "ytd" },
{ label: "Last 365 days", days: 365, value: "365d" },
];

View File

@@ -1,10 +1,153 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
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";
import TimePeriodFilter from "../components/analytics/TimePeriodFilter";
import type { TimePeriod } from "../lib/timePeriods";
import { TIME_PERIODS } from "../lib/timePeriods";
function AnalyticsDashboard() {
// Default to Last 365 days
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>(
TIME_PERIODS.find((p) => p.value === "365d") || TIME_PERIODS[3]
);
// Fetch analytics data
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ["transaction-stats", selectedPeriod.days],
queryFn: () => apiClient.getTransactionStats(selectedPeriod.days),
});
const { data: accounts, isLoading: accountsLoading } = useQuery({
queryKey: ["accounts"],
queryFn: () => apiClient.getAccounts(),
});
const { data: balances, isLoading: balancesLoading } = useQuery({
queryKey: ["historical-balances", selectedPeriod.days],
queryFn: () => apiClient.getHistoricalBalances(selectedPeriod.days),
});
const isLoading = statsLoading || accountsLoading || balancesLoading;
if (isLoading) {
return (
<div className="p-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-48 mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-32 bg-gray-200 rounded"></div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="h-96 bg-gray-200 rounded"></div>
<div className="h-96 bg-gray-200 rounded"></div>
</div>
</div>
</div>
);
}
const totalBalance = accounts?.reduce((sum, account) => {
const primaryBalance = account.balances?.[0]?.amount || 0;
return sum + primaryBalance;
}, 0) || 0;
return (
<div className="p-6 space-y-8">
{/* Time Period Filter */}
<TimePeriodFilter
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
className="bg-white rounded-lg shadow p-4 border border-gray-200"
/>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard
title="Total Balance"
value={`${totalBalance.toLocaleString()}`}
subtitle={`Across ${accounts?.length || 0} accounts`}
icon={DollarSign}
/>
<StatCard
title="Total Transactions"
value={stats?.total_transactions || 0}
subtitle={`Last ${stats?.period_days || 0} days`}
icon={Activity}
/>
<StatCard
title="Total Income"
value={`${(stats?.total_income || 0).toLocaleString()}`}
subtitle="Inflows this period"
icon={TrendingUp}
className="border-green-200"
/>
<StatCard
title="Total Expenses"
value={`${(stats?.total_expenses || 0).toLocaleString()}`}
subtitle="Outflows this period"
icon={TrendingDown}
className="border-red-200"
/>
</div>
{/* Additional Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard
title="Net Change"
value={`${(stats?.net_change || 0).toLocaleString()}`}
subtitle="Income minus expenses"
icon={CreditCard}
className={
(stats?.net_change || 0) >= 0 ? "border-green-200" : "border-red-200"
}
/>
<StatCard
title="Average Transaction"
value={`${Math.abs(stats?.average_transaction || 0).toLocaleString()}`}
subtitle="Per transaction"
icon={Activity}
/>
<StatCard
title="Active Accounts"
value={stats?.accounts_included || 0}
subtitle="With recent activity"
icon={Users}
/>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
<BalanceChart data={balances || []} accounts={accounts || []} />
</div>
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
<TransactionDistribution accounts={accounts || []} />
</div>
</div>
{/* Monthly Trends */}
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
<MonthlyTrends days={selectedPeriod.days} />
</div>
</div>
);
}
export const Route = createFileRoute("/analytics")({
component: () => (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Analytics</h3>
<p className="text-gray-600">Analytics dashboard coming soon...</p>
</div>
),
component: AnalyticsDashboard,
});

View File

@@ -59,6 +59,17 @@ export interface RawTransactionData {
[key: string]: unknown; // Allow additional fields
}
// Type for analytics transaction data
export interface AnalyticsTransaction {
transaction_id: string;
date: string;
description: string;
amount: number;
currency: string;
status: string;
account_id: string;
}
export interface Transaction {
transaction_id: string; // NEW: stable bank-provided transaction ID
internal_transaction_id: string | null; // OLD: unstable GoCardless ID
@@ -188,3 +199,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;
}

View File

@@ -0,0 +1,68 @@
"""Generate sample database command."""
import click
from pathlib import Path
@click.command()
@click.option(
"--database",
type=click.Path(path_type=Path),
help="Path to database file (default: uses LEGGEN_DATABASE_PATH or ~/.config/leggen/leggen-dev.db)",
)
@click.option(
"--accounts",
type=int,
default=3,
help="Number of sample accounts to generate (default: 3)",
)
@click.option(
"--transactions",
type=int,
default=50,
help="Number of transactions per account (default: 50)",
)
@click.option(
"--force",
is_flag=True,
help="Overwrite existing database without confirmation",
)
@click.pass_context
def generate_sample_db(
ctx: click.Context, database: Path, accounts: int, transactions: int, force: bool
):
"""Generate a sample database with realistic financial data for testing."""
# Import here to avoid circular imports
import sys
import subprocess
from pathlib import Path as PathlibPath
# Get the script path
script_path = (
PathlibPath(__file__).parent.parent.parent / "scripts" / "generate_sample_db.py"
)
# Build command arguments
cmd = [sys.executable, str(script_path)]
if database:
cmd.extend(["--database", str(database)])
cmd.extend(["--accounts", str(accounts)])
cmd.extend(["--transactions", str(transactions)])
if force:
cmd.append("--force")
# Execute the script
try:
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
click.echo(f"Error generating sample database: {e}")
ctx.exit(1)
# Export the command
generate_sample_db = generate_sample_db

View File

@@ -5,14 +5,12 @@ from sqlite3 import IntegrityError
import click
from leggen.utils.text import success, warning
from leggen.utils.paths import path_manager
def persist_balances(ctx: click.Context, balance: dict):
# Connect to SQLite database
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
db_path = path_manager.get_database_path()
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
@@ -108,10 +106,7 @@ def persist_balances(ctx: click.Context, balance: dict):
def persist_transactions(ctx: click.Context, account: str, transactions: list) -> list:
# Connect to SQLite database
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
db_path = path_manager.get_database_path()
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
@@ -216,9 +211,7 @@ def get_transactions(
search=None,
):
"""Get transactions from SQLite database with optional filtering"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path = path_manager.get_database_path()
if not db_path.exists():
return []
conn = sqlite3.connect(str(db_path))
@@ -288,9 +281,7 @@ def get_transactions(
def get_balances(account_id=None):
"""Get latest balances from SQLite database"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path = path_manager.get_database_path()
if not db_path.exists():
return []
conn = sqlite3.connect(str(db_path))
@@ -329,9 +320,7 @@ def get_balances(account_id=None):
def get_account_summary(account_id):
"""Get basic account info from transactions table (avoids GoCardless API call)"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path = path_manager.get_database_path()
if not db_path.exists():
return None
conn = sqlite3.connect(str(db_path))
@@ -365,9 +354,7 @@ def get_account_summary(account_id):
def get_transaction_count(account_id=None, **filters):
"""Get total count of transactions matching filters"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path = path_manager.get_database_path()
if not db_path.exists():
return 0
conn = sqlite3.connect(str(db_path))
@@ -414,10 +401,7 @@ def get_transaction_count(account_id=None, **filters):
def persist_account(account_data: dict):
"""Persist account details to SQLite database"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
db_path = path_manager.get_database_path()
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
@@ -485,9 +469,7 @@ def persist_account(account_data: dict):
def get_accounts(account_ids=None):
"""Get account details from SQLite database"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path = path_manager.get_database_path()
if not db_path.exists():
return []
conn = sqlite3.connect(str(db_path))
@@ -519,9 +501,7 @@ def get_accounts(account_ids=None):
def get_account(account_id: str):
"""Get specific account details from SQLite database"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path = path_manager.get_database_path()
if not db_path.exists():
return None
conn = sqlite3.connect(str(db_path))
@@ -540,3 +520,131 @@ def get_account(account_id: str):
except Exception as e:
conn.close()
raise e
def get_historical_balances(account_id=None, days=365):
"""Get historical balance progression based on transaction history"""
db_path = path_manager.get_database_path()
if not db_path.exists():
return []
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
try:
# Get current balance for each account/type to use as the final balance
current_balances_query = """
SELECT account_id, type, amount, currency
FROM balances b1
WHERE b1.timestamp = (
SELECT MAX(b2.timestamp)
FROM balances b2
WHERE b2.account_id = b1.account_id AND b2.type = b1.type
)
"""
params = []
if account_id:
current_balances_query += " AND b1.account_id = ?"
params.append(account_id)
cursor.execute(current_balances_query, params)
current_balances = {
(row['account_id'], row['type']): {
'amount': row['amount'],
'currency': row['currency']
}
for row in cursor.fetchall()
}
# Get transactions for the specified period, ordered by date descending
from datetime import datetime, timedelta
cutoff_date = (datetime.now() - timedelta(days=days)).isoformat()
transactions_query = """
SELECT accountId, transactionDate, transactionValue
FROM transactions
WHERE transactionDate >= ?
"""
if account_id:
transactions_query += " AND accountId = ?"
params = [cutoff_date, account_id]
else:
params = [cutoff_date]
transactions_query += " ORDER BY transactionDate DESC"
cursor.execute(transactions_query, params)
transactions = cursor.fetchall()
# Calculate historical balances by working backwards from current balance
historical_balances = []
account_running_balances: dict[str, dict[str, float]] = {}
# Initialize running balances with current balances
for (acc_id, balance_type), balance_info in current_balances.items():
if acc_id not in account_running_balances:
account_running_balances[acc_id] = {}
account_running_balances[acc_id][balance_type] = balance_info['amount']
# Group transactions by date
from collections import defaultdict
transactions_by_date = defaultdict(list)
for txn in transactions:
date_str = txn['transactionDate'][:10] # Extract just the date part
transactions_by_date[date_str].append(txn)
# Generate historical balance points
# Start from today and work backwards
current_date = datetime.now().date()
for day_offset in range(0, days, 7): # Sample every 7 days for performance
target_date = current_date - timedelta(days=day_offset)
target_date_str = target_date.isoformat()
# For each account, create balance entries
for acc_id in account_running_balances:
for balance_type in ['closingBooked']: # Focus on closingBooked for the chart
if balance_type in account_running_balances[acc_id]:
balance_amount = account_running_balances[acc_id][balance_type]
currency = current_balances.get((acc_id, balance_type), {}).get('currency', 'EUR')
historical_balances.append({
'id': f"{acc_id}_{balance_type}_{target_date_str}",
'account_id': acc_id,
'balance_amount': balance_amount,
'balance_type': balance_type,
'currency': currency,
'reference_date': target_date_str,
'created_at': None,
'updated_at': None
})
# Subtract transactions that occurred on this date and later dates
# to simulate going back in time
for date_str in list(transactions_by_date.keys()):
if date_str >= target_date_str:
for txn in transactions_by_date[date_str]:
acc_id = txn['accountId']
amount = txn['transactionValue']
if acc_id in account_running_balances:
for balance_type in account_running_balances[acc_id]:
account_running_balances[acc_id][balance_type] -= amount
# Remove processed transactions to avoid double-processing
del transactions_by_date[date_str]
conn.close()
# Sort by date for proper chronological order
historical_balances.sort(key=lambda x: x['reference_date'])
return historical_balances
except Exception as e:
conn.close()
raise e

View File

@@ -7,6 +7,7 @@ import click
from leggen.utils.config import load_config
from leggen.utils.text import error
from leggen.utils.paths import path_manager
cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "commands"))
@@ -77,7 +78,7 @@ class Group(click.Group):
"-c",
"--config",
type=click.Path(dir_okay=False),
default=Path.home() / ".config" / "leggen" / "config.toml",
default=lambda: str(path_manager.get_config_file_path()),
show_default=True,
callback=load_config,
is_eager=True,
@@ -86,6 +87,20 @@ class Group(click.Group):
show_envvar=True,
help="Path to TOML configuration file",
)
@click.option(
"--config-dir",
type=click.Path(exists=False, file_okay=False, path_type=Path),
envvar="LEGGEN_CONFIG_DIR",
show_envvar=True,
help="Directory containing configuration files (default: ~/.config/leggen)",
)
@click.option(
"--database",
type=click.Path(dir_okay=False, path_type=Path),
envvar="LEGGEN_DATABASE_PATH",
show_envvar=True,
help="Path to SQLite database file (default: <config-dir>/leggen.db)",
)
@click.option(
"--api-url",
type=str,
@@ -100,7 +115,7 @@ class Group(click.Group):
)
@click.version_option(package_name="leggen")
@click.pass_context
def cli(ctx: click.Context, api_url: str):
def cli(ctx: click.Context, config_dir: Path, database: Path, api_url: str):
"""
Leggen: An Open Banking CLI
"""
@@ -109,5 +124,11 @@ def cli(ctx: click.Context, api_url: str):
if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]:
return
# Set up path manager with user-provided paths
if config_dir:
path_manager.set_config_dir(config_dir)
if database:
path_manager.set_database_path(database)
# Store API URL in context for commands to use
ctx.obj["api_url"] = api_url

78
leggen/utils/paths.py Normal file
View File

@@ -0,0 +1,78 @@
"""Centralized path management for Leggen."""
import contextlib
import os
from pathlib import Path
from typing import Optional
class PathManager:
"""Manages configurable paths for config and database files."""
def __init__(self):
self._config_dir: Optional[Path] = None
self._database_path: Optional[Path] = None
def get_config_dir(self) -> Path:
"""Get the configuration directory."""
if self._config_dir is not None:
return self._config_dir
# Check environment variable first
config_dir = os.environ.get("LEGGEN_CONFIG_DIR")
if config_dir:
return Path(config_dir)
# Default to ~/.config/leggen
return Path.home() / ".config" / "leggen"
def set_config_dir(self, path: Path) -> None:
"""Set the configuration directory."""
self._config_dir = Path(path)
def get_config_file_path(self) -> Path:
"""Get the configuration file path."""
return self.get_config_dir() / "config.toml"
def get_database_path(self) -> Path:
"""Get the database file path and ensure the directory exists."""
if self._database_path is not None:
db_path = self._database_path
else:
# Check environment variable first
database_path = os.environ.get("LEGGEN_DATABASE_PATH")
if database_path:
db_path = Path(database_path)
else:
# Default to config_dir/leggen.db
db_path = self.get_config_dir() / "leggen.db"
# Try to ensure the directory exists, but handle permission errors gracefully
with contextlib.suppress(PermissionError, OSError):
db_path.parent.mkdir(parents=True, exist_ok=True)
return db_path
def set_database_path(self, path: Path) -> None:
"""Set the database file path."""
self._database_path = Path(path)
def get_auth_file_path(self) -> Path:
"""Get the authentication file path."""
return self.get_config_dir() / "auth.json"
def ensure_config_dir_exists(self) -> None:
"""Ensure the configuration directory exists."""
self.get_config_dir().mkdir(parents=True, exist_ok=True)
def ensure_database_dir_exists(self) -> None:
"""Ensure the database directory exists.
Note: get_database_path() now automatically ensures the directory exists,
so this method is mainly for explicit directory creation in tests.
"""
self.get_database_path().parent.mkdir(parents=True, exist_ok=True)
# Global instance for the application
path_manager = PathManager()

View File

@@ -215,6 +215,31 @@ async def get_all_balances() -> APIResponse:
) from e
@router.get("/balances/history", response_model=APIResponse)
async def get_historical_balances(
days: Optional[int] = Query(default=365, le=1095, ge=1, description="Number of days of history to retrieve"),
account_id: Optional[str] = Query(default=None, description="Filter by specific account ID")
) -> APIResponse:
"""Get historical balance progression calculated from transaction history"""
try:
# Get historical balances from database
historical_balances = await database_service.get_historical_balances_from_db(
account_id=account_id, days=days or 365
)
return APIResponse(
success=True,
data=historical_balances,
message=f"Retrieved {len(historical_balances)} historical balance points over {days} days",
)
except Exception as e:
logger.error(f"Failed to get historical balances: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to get historical balances: {str(e)}"
) from e
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
async def get_account_transactions(
account_id: str,

View File

@@ -202,3 +202,55 @@ async def get_transaction_stats(
raise HTTPException(
status_code=500, detail=f"Failed to get transaction stats: {str(e)}"
) from e
@router.get("/transactions/analytics", response_model=APIResponse)
async def get_transactions_for_analytics(
days: int = Query(default=365, description="Number of days to include"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> APIResponse:
"""Get all transactions for analytics (no pagination) for the last N days"""
try:
# Date range for analytics
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
# Format dates for database query
date_from = start_date.isoformat()
date_to = end_date.isoformat()
# Get ALL transactions from database (no limit for analytics)
transactions = await database_service.get_transactions_from_db(
account_id=account_id,
date_from=date_from,
date_to=date_to,
limit=None, # No limit - get all transactions
)
# Transform for frontend (summary format)
transaction_summaries = [
{
"transaction_id": txn["transactionId"],
"date": txn["transactionDate"],
"description": txn["description"],
"amount": txn["transactionValue"],
"currency": txn["transactionCurrency"],
"status": txn["transactionStatus"],
"account_id": txn["accountId"],
}
for txn in transactions
]
return APIResponse(
success=True,
data=transaction_summaries,
message=f"Retrieved {len(transaction_summaries)} transactions for analytics",
)
except Exception as e:
logger.error(f"Failed to get transactions for analytics: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to get analytics transactions: {str(e)}"
) from e

View File

@@ -5,6 +5,7 @@ from pathlib import Path
from typing import Dict, Any, Optional
from loguru import logger
from leggen.utils.paths import path_manager
class Config:
@@ -22,10 +23,9 @@ class Config:
return self._config
if config_path is None:
config_path = os.environ.get(
"LEGGEN_CONFIG_FILE",
str(Path.home() / ".config" / "leggen" / "config.toml"),
)
config_path = os.environ.get("LEGGEN_CONFIG_FILE")
if not config_path:
config_path = str(path_manager.get_config_file_path())
self._config_path = config_path
@@ -52,10 +52,9 @@ class Config:
config_data = self._config
if config_path is None:
config_path = self._config_path or os.environ.get(
"LEGGEN_CONFIG_FILE",
str(Path.home() / ".config" / "leggen" / "config.toml"),
)
config_path = self._config_path or os.environ.get("LEGGEN_CONFIG_FILE")
if not config_path:
config_path = str(path_manager.get_config_file_path())
if config_path is None:
raise ValueError("No config path specified")

View File

@@ -121,6 +121,8 @@ def create_app() -> FastAPI:
def main():
import argparse
from pathlib import Path
from leggen.utils.paths import path_manager
parser = argparse.ArgumentParser(description="Start the Leggend API service")
parser.add_argument(
@@ -132,8 +134,24 @@ def main():
parser.add_argument(
"--port", type=int, default=8000, help="Port to bind to (default: 8000)"
)
parser.add_argument(
"--config-dir",
type=Path,
help="Directory containing configuration files (default: ~/.config/leggen)",
)
parser.add_argument(
"--database",
type=Path,
help="Path to SQLite database file (default: <config-dir>/leggen.db)",
)
args = parser.parse_args()
# Set up path manager with user-provided paths
if args.config_dir:
path_manager.set_config_dir(args.config_dir)
if args.database:
path_manager.set_database_path(args.database)
if args.reload:
# Use string import for reload to work properly
uvicorn.run(

View File

@@ -6,6 +6,7 @@ from loguru import logger
from leggend.config import config
import leggen.database.sqlite as sqlite_db
from leggen.utils.paths import path_manager
class DatabaseService:
@@ -117,7 +118,7 @@ class DatabaseService:
async def get_transactions_from_db(
self,
account_id: Optional[str] = None,
limit: Optional[int] = 100,
limit: Optional[int] = None, # None means no limit, used for stats
offset: Optional[int] = 0,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
@@ -133,7 +134,7 @@ class DatabaseService:
try:
transactions = sqlite_db.get_transactions(
account_id=account_id,
limit=limit or 100,
limit=limit, # Pass limit as-is, None means no limit
offset=offset or 0,
date_from=date_from,
date_to=date_to,
@@ -194,6 +195,22 @@ class DatabaseService:
logger.error(f"Failed to get balances from database: {e}")
return []
async def get_historical_balances_from_db(
self, account_id: Optional[str] = None, days: int = 365
) -> List[Dict[str, Any]]:
"""Get historical balance progression from SQLite database"""
if not self.sqlite_enabled:
logger.warning("SQLite database disabled, cannot read historical balances")
return []
try:
balances = sqlite_db.get_historical_balances(account_id=account_id, days=days)
logger.debug(f"Retrieved {len(balances)} historical balance points from database")
return balances
except Exception as e:
logger.error(f"Failed to get historical balances from database: {e}")
return []
async def get_account_summary_from_db(
self, account_id: str
) -> Optional[Dict[str, Any]]:
@@ -280,9 +297,7 @@ class DatabaseService:
async def _check_balance_timestamp_migration_needed(self) -> bool:
"""Check if balance timestamps need migration"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
@@ -310,9 +325,7 @@ class DatabaseService:
async def _migrate_balance_timestamps(self):
"""Convert all Unix timestamps to datetime strings"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
@@ -399,9 +412,7 @@ class DatabaseService:
async def _check_null_transaction_ids_migration_needed(self) -> bool:
"""Check if null transaction IDs need migration"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
@@ -429,9 +440,8 @@ class DatabaseService:
async def _migrate_null_transaction_ids(self):
"""Populate null internalTransactionId fields using transactionId from raw data"""
import uuid
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
@@ -538,9 +548,7 @@ class DatabaseService:
async def _check_composite_key_migration_needed(self) -> bool:
"""Check if composite key migration is needed"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
@@ -586,9 +594,7 @@ class DatabaseService:
async def _migrate_to_composite_key(self):
"""Migrate transactions table to use composite primary key (accountId, transactionId)"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
@@ -704,10 +710,7 @@ class DatabaseService:
try:
import sqlite3
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
db_path = path_manager.get_database_path()
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
@@ -786,10 +789,7 @@ class DatabaseService:
import sqlite3
import json
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
db_path = path_manager.get_database_path()
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
@@ -888,11 +888,6 @@ class DatabaseService:
) -> None:
"""Persist account details to SQLite"""
try:
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
# Use the sqlite_db module function
sqlite_db.persist_account(account_data)

View File

@@ -6,6 +6,7 @@ from typing import Dict, Any, List
from loguru import logger
from leggend.config import config
from leggen.utils.paths import path_manager
def _log_rate_limits(response):
@@ -39,8 +40,8 @@ class GoCardlessService:
if self._token:
return self._token
# Use ~/.config/leggen for consistency with main config
auth_file = Path.home() / ".config" / "leggen" / "auth.json"
# Use path manager for auth file
auth_file = path_manager.get_auth_file_path()
if auth_file.exists():
try:

View File

@@ -1,6 +1,6 @@
[project]
name = "leggen"
version = "2025.9.7"
version = "2025.9.10"
description = "An Open Banking CLI"
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
requires-python = "~=3.13.0"

546
scripts/generate_sample_db.py Executable file
View File

@@ -0,0 +1,546 @@
#!/usr/bin/env python3
"""Sample database generator for Leggen testing and development."""
import json
import random
import sqlite3
import sys
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Any
import click
# Add the project root to the Python path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
# Import after path setup - this is necessary for the script to work
from leggen.utils.paths import path_manager # noqa: E402
class SampleDataGenerator:
"""Generates realistic sample data for testing Leggen."""
def __init__(self, db_path: Path):
self.db_path = db_path
self.institutions = [
{
"id": "REVOLUT_REVOLT21",
"name": "Revolut",
"bic": "REVOLT21",
"country": "LT",
},
{
"id": "BANCOBPI_BBPIPTPL",
"name": "Banco BPI",
"bic": "BBPIPTPL",
"country": "PT",
},
{
"id": "MONZO_MONZGB2L",
"name": "Monzo Bank",
"bic": "MONZGB2L",
"country": "GB",
},
{
"id": "NUBANK_NUPBBR25",
"name": "Nu Pagamentos",
"bic": "NUPBBR25",
"country": "BR",
},
]
self.transaction_types = [
{
"description": "Grocery Store",
"amount_range": (-150, -20),
"frequency": 0.3,
},
{"description": "Coffee Shop", "amount_range": (-15, -3), "frequency": 0.2},
{
"description": "Gas Station",
"amount_range": (-80, -30),
"frequency": 0.1,
},
{
"description": "Online Shopping",
"amount_range": (-200, -25),
"frequency": 0.15,
},
{
"description": "Restaurant",
"amount_range": (-60, -15),
"frequency": 0.15,
},
{"description": "Salary", "amount_range": (2500, 5000), "frequency": 0.02},
{
"description": "ATM Withdrawal",
"amount_range": (-200, -20),
"frequency": 0.05,
},
{
"description": "Transfer to Savings",
"amount_range": (-1000, -100),
"frequency": 0.03,
},
]
def ensure_database_dir(self):
"""Ensure database directory exists."""
self.db_path.parent.mkdir(parents=True, exist_ok=True)
def create_tables(self):
"""Create database tables."""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
# Create accounts table
cursor.execute("""
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
institution_id TEXT,
status TEXT,
iban TEXT,
name TEXT,
currency TEXT,
created DATETIME,
last_accessed DATETIME,
last_updated DATETIME
)
""")
# Create transactions table with composite primary key
cursor.execute("""
CREATE TABLE IF NOT EXISTS transactions (
accountId TEXT NOT NULL,
transactionId TEXT NOT NULL,
internalTransactionId TEXT,
institutionId TEXT,
iban TEXT,
transactionDate DATETIME,
description TEXT,
transactionValue REAL,
transactionCurrency TEXT,
transactionStatus TEXT,
rawTransaction JSON,
PRIMARY KEY (accountId, transactionId)
)
""")
# Create balances table
cursor.execute("""
CREATE TABLE IF NOT EXISTS balances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT,
bank TEXT,
status TEXT,
iban TEXT,
amount REAL,
currency TEXT,
type TEXT,
timestamp DATETIME
)
""")
# Create indexes
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_transactions_internal_id ON transactions(internalTransactionId)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transactionDate)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_transactions_account_date ON transactions(accountId, transactionDate)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_transactions_amount ON transactions(transactionValue)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_balances_account_id ON balances(account_id)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_balances_timestamp ON balances(timestamp)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_balances_account_type_timestamp ON balances(account_id, type, timestamp)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_accounts_institution_id ON accounts(institution_id)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status)"
)
conn.commit()
conn.close()
def generate_iban(self, country_code: str) -> str:
"""Generate a realistic IBAN for the given country."""
ibans = {
"LT": lambda: f"LT{random.randint(10, 99)}{random.randint(10000, 99999)}{random.randint(10000000, 99999999)}",
"PT": lambda: f"PT{random.randint(10, 99)}{random.randint(1000, 9999)}{random.randint(1000, 9999)}{random.randint(10000000000, 99999999999)}",
"GB": lambda: f"GB{random.randint(10, 99)}MONZ{random.randint(100000, 999999)}{random.randint(100000, 999999)}",
"BR": lambda: f"BR{random.randint(10, 99)}{random.randint(10000000, 99999999)}{random.randint(1000, 9999)}{random.randint(10000000, 99999999)}",
}
return ibans.get(
country_code,
lambda: f"{country_code}{random.randint(1000000000000000, 9999999999999999)}",
)()
def generate_accounts(self, num_accounts: int = 3) -> List[Dict[str, Any]]:
"""Generate sample accounts."""
accounts = []
base_date = datetime.now() - timedelta(days=90)
for i in range(num_accounts):
institution = random.choice(self.institutions)
account_id = f"account-{i + 1:03d}-{random.randint(1000, 9999)}"
account = {
"id": account_id,
"institution_id": institution["id"],
"status": "READY",
"iban": self.generate_iban(institution["country"]),
"name": f"Personal Account {i + 1}",
"currency": "EUR",
"created": (
base_date + timedelta(days=random.randint(0, 30))
).isoformat(),
"last_accessed": (
datetime.now() - timedelta(hours=random.randint(1, 48))
).isoformat(),
"last_updated": datetime.now().isoformat(),
}
accounts.append(account)
return accounts
def generate_transactions(
self, accounts: List[Dict[str, Any]], num_transactions_per_account: int = 50
) -> List[Dict[str, Any]]:
"""Generate sample transactions for accounts."""
transactions = []
base_date = datetime.now() - timedelta(days=60)
for account in accounts:
account_transactions = []
current_balance = random.uniform(500, 3000)
for i in range(num_transactions_per_account):
# Choose transaction type based on frequency weights
transaction_type = random.choices(
self.transaction_types,
weights=[t["frequency"] for t in self.transaction_types],
)[0]
# Generate transaction amount
min_amount, max_amount = transaction_type["amount_range"]
amount = round(random.uniform(min_amount, max_amount), 2)
# Generate transaction date (more recent transactions are more likely)
days_ago = random.choices(
range(60), weights=[1.5 ** (60 - d) for d in range(60)]
)[0]
transaction_date = base_date + timedelta(
days=days_ago,
hours=random.randint(6, 22),
minutes=random.randint(0, 59),
)
# Generate transaction IDs
transaction_id = f"bank-txn-{account['id']}-{i + 1:04d}"
internal_transaction_id = f"int-txn-{random.randint(100000, 999999)}"
# Create realistic descriptions
descriptions = {
"Grocery Store": [
"TESCO",
"SAINSBURY'S",
"LIDL",
"ALDI",
"WALMART",
"CARREFOUR",
],
"Coffee Shop": [
"STARBUCKS",
"COSTA COFFEE",
"PRET A MANGER",
"LOCAL CAFE",
],
"Gas Station": ["BP", "SHELL", "ESSO", "GALP", "PETROBRAS"],
"Online Shopping": ["AMAZON", "EBAY", "ZALANDO", "ASOS", "APPLE"],
"Restaurant": [
"PIZZA HUT",
"MCDONALD'S",
"BURGER KING",
"LOCAL RESTAURANT",
],
"Salary": ["MONTHLY SALARY", "PAYROLL DEPOSIT", "SALARY PAYMENT"],
"ATM Withdrawal": ["ATM WITHDRAWAL", "CASH WITHDRAWAL"],
"Transfer to Savings": ["SAVINGS TRANSFER", "INVESTMENT TRANSFER"],
}
specific_descriptions = descriptions.get(
transaction_type["description"], [transaction_type["description"]]
)
description = random.choice(specific_descriptions)
# Create raw transaction (simplified GoCardless format)
raw_transaction = {
"transactionId": transaction_id,
"bookingDate": transaction_date.strftime("%Y-%m-%d"),
"valueDate": transaction_date.strftime("%Y-%m-%d"),
"transactionAmount": {
"amount": str(amount),
"currency": account["currency"],
},
"remittanceInformationUnstructured": description,
"bankTransactionCode": "PMNT" if amount < 0 else "RCDT",
}
# Determine status (most are booked, some recent ones might be pending)
status = (
"pending" if days_ago < 2 and random.random() < 0.1 else "booked"
)
transaction = {
"accountId": account["id"],
"transactionId": transaction_id,
"internalTransactionId": internal_transaction_id,
"institutionId": account["institution_id"],
"iban": account["iban"],
"transactionDate": transaction_date.isoformat(),
"description": description,
"transactionValue": amount,
"transactionCurrency": account["currency"],
"transactionStatus": status,
"rawTransaction": raw_transaction,
}
account_transactions.append(transaction)
current_balance += amount
# Sort transactions by date for realistic ordering
account_transactions.sort(key=lambda x: x["transactionDate"])
transactions.extend(account_transactions)
return transactions
def generate_balances(self, accounts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Generate sample balances for accounts."""
balances = []
for account in accounts:
# Calculate balance from transactions (simplified)
base_balance = random.uniform(500, 2000)
balance_types = ["interimAvailable", "closingBooked", "authorised"]
for balance_type in balance_types:
# Add some variation to balance types
variation = (
random.uniform(-50, 50) if balance_type != "interimAvailable" else 0
)
balance_amount = base_balance + variation
balance = {
"account_id": account["id"],
"bank": account["institution_id"],
"status": account["status"],
"iban": account["iban"],
"amount": round(balance_amount, 2),
"currency": account["currency"],
"type": balance_type,
"timestamp": datetime.now().isoformat(),
}
balances.append(balance)
return balances
def insert_data(
self,
accounts: List[Dict[str, Any]],
transactions: List[Dict[str, Any]],
balances: List[Dict[str, Any]],
):
"""Insert generated data into the database."""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
# Insert accounts
for account in accounts:
cursor.execute(
"""
INSERT OR REPLACE INTO accounts
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
account["id"],
account["institution_id"],
account["status"],
account["iban"],
account["name"],
account["currency"],
account["created"],
account["last_accessed"],
account["last_updated"],
),
)
# Insert transactions
for transaction in transactions:
cursor.execute(
"""
INSERT OR REPLACE INTO transactions
(accountId, transactionId, internalTransactionId, institutionId, iban,
transactionDate, description, transactionValue, transactionCurrency,
transactionStatus, rawTransaction)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
transaction["accountId"],
transaction["transactionId"],
transaction["internalTransactionId"],
transaction["institutionId"],
transaction["iban"],
transaction["transactionDate"],
transaction["description"],
transaction["transactionValue"],
transaction["transactionCurrency"],
transaction["transactionStatus"],
json.dumps(transaction["rawTransaction"]),
),
)
# Insert balances
for balance in balances:
cursor.execute(
"""
INSERT INTO balances
(account_id, bank, status, iban, amount, currency, type, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
balance["account_id"],
balance["bank"],
balance["status"],
balance["iban"],
balance["amount"],
balance["currency"],
balance["type"],
balance["timestamp"],
),
)
conn.commit()
conn.close()
def generate_sample_database(
self, num_accounts: int = 3, num_transactions_per_account: int = 50
):
"""Generate complete sample database."""
click.echo(f"🗄️ Creating sample database at: {self.db_path}")
self.ensure_database_dir()
self.create_tables()
click.echo(f"👥 Generating {num_accounts} sample accounts...")
accounts = self.generate_accounts(num_accounts)
click.echo(
f"💳 Generating {num_transactions_per_account} transactions per account..."
)
transactions = self.generate_transactions(
accounts, num_transactions_per_account
)
click.echo("💰 Generating account balances...")
balances = self.generate_balances(accounts)
click.echo("💾 Inserting data into database...")
self.insert_data(accounts, transactions, balances)
# Print summary
click.echo("\n✅ Sample database created successfully!")
click.echo("📊 Summary:")
click.echo(f" - Accounts: {len(accounts)}")
click.echo(f" - Transactions: {len(transactions)}")
click.echo(f" - Balances: {len(balances)}")
click.echo(f" - Database: {self.db_path}")
# Show account details
click.echo("\n📋 Sample accounts:")
for account in accounts:
institution_name = next(
inst["name"]
for inst in self.institutions
if inst["id"] == account["institution_id"]
)
click.echo(f" - {account['id']} ({institution_name}) - {account['iban']}")
@click.command()
@click.option(
"--database",
type=click.Path(path_type=Path),
help="Path to database file (default: uses LEGGEN_DATABASE_PATH or ~/.config/leggen/leggen-dev.db)",
)
@click.option(
"--accounts",
type=int,
default=3,
help="Number of sample accounts to generate (default: 3)",
)
@click.option(
"--transactions",
type=int,
default=50,
help="Number of transactions per account (default: 50)",
)
@click.option(
"--force",
is_flag=True,
help="Overwrite existing database without confirmation",
)
def main(database: Path, accounts: int, transactions: int, force: bool):
"""Generate a sample database with realistic financial data for testing Leggen."""
# Determine database path
if database:
db_path = database
else:
# Use development database by default to avoid overwriting production data
import os
env_path = os.environ.get("LEGGEN_DATABASE_PATH")
if env_path:
db_path = Path(env_path)
else:
# Default to development database in config directory
db_path = path_manager.get_config_dir() / "leggen-dev.db"
# Check if database exists and ask for confirmation
if db_path.exists() and not force:
click.echo(f"⚠️ Database already exists: {db_path}")
if not click.confirm("Do you want to overwrite it?"):
click.echo("Aborted.")
return
# Generate the sample database
generator = SampleDataGenerator(db_path)
generator.generate_sample_database(accounts, transactions)
# Show usage instructions
click.echo("\n🚀 Usage instructions:")
click.echo("To use this sample database with leggen commands:")
click.echo(f" export LEGGEN_DATABASE_PATH={db_path}")
click.echo(" leggen transactions")
click.echo("")
click.echo("To use this sample database with leggend API:")
click.echo(f" leggend --database {db_path}")
if __name__ == "__main__":
main()

View File

@@ -86,23 +86,17 @@ def api_client(fastapi_app):
@pytest.fixture
def mock_db_path(temp_db_path):
"""Mock the database path to use temporary database for testing."""
from pathlib import Path
from leggen.utils.paths import path_manager
# Create the expected directory structure
temp_home = temp_db_path.parent
config_dir = temp_home / ".config" / "leggen"
config_dir.mkdir(parents=True, exist_ok=True)
# Set the path manager to use the temporary database
original_database_path = path_manager._database_path
path_manager.set_database_path(temp_db_path)
# Create the expected database path
expected_db_path = config_dir / "leggen.db"
# Mock Path.home to return our temp directory
def mock_home():
return temp_home
# Patch Path.home in the main pathlib module
with patch.object(Path, "home", staticmethod(mock_home)):
yield expected_db_path
try:
yield temp_db_path
finally:
# Restore original path
path_manager._database_path = original_database_path
@pytest.fixture

View File

@@ -0,0 +1,104 @@
"""Tests for analytics fixes to ensure all transactions are used in statistics."""
import pytest
from datetime import datetime, timedelta
from unittest.mock import Mock, AsyncMock, patch
from fastapi.testclient import TestClient
from leggend.main import create_app
from leggend.services.database_service import DatabaseService
class TestAnalyticsFix:
"""Test analytics fixes for transaction limits"""
@pytest.fixture
def client(self):
app = create_app()
return TestClient(app)
@pytest.fixture
def mock_database_service(self):
return Mock(spec=DatabaseService)
@pytest.mark.asyncio
async def test_transaction_stats_uses_all_transactions(self, mock_database_service):
"""Test that transaction stats endpoint uses all transactions (not limited to 100)"""
# Mock data for 600 transactions (simulating the issue)
mock_transactions = []
for i in range(600):
mock_transactions.append({
"transactionId": f"txn-{i}",
"transactionDate": (datetime.now() - timedelta(days=i % 365)).isoformat(),
"description": f"Transaction {i}",
"transactionValue": 10.0 if i % 2 == 0 else -5.0,
"transactionCurrency": "EUR",
"transactionStatus": "booked",
"accountId": f"account-{i % 3}",
})
mock_database_service.get_transactions_from_db = AsyncMock(return_value=mock_transactions)
# Test that the endpoint calls get_transactions_from_db with limit=None
with patch('leggend.api.routes.transactions.database_service', mock_database_service):
app = create_app()
client = TestClient(app)
response = client.get("/api/v1/transactions/stats?days=365")
assert response.status_code == 200
data = response.json()
# Verify that limit=None was passed to get all transactions
mock_database_service.get_transactions_from_db.assert_called_once()
call_args = mock_database_service.get_transactions_from_db.call_args
assert call_args.kwargs.get("limit") is None, "Stats endpoint should pass limit=None to get all transactions"
# Verify that the response contains stats for all 600 transactions
assert data["success"] is True
stats = data["data"]
assert stats["total_transactions"] == 600, "Should process all 600 transactions, not just 100"
# Verify calculations are correct for all transactions
expected_income = sum(txn["transactionValue"] for txn in mock_transactions if txn["transactionValue"] > 0)
expected_expenses = sum(abs(txn["transactionValue"]) for txn in mock_transactions if txn["transactionValue"] < 0)
assert stats["total_income"] == expected_income
assert stats["total_expenses"] == expected_expenses
@pytest.mark.asyncio
async def test_analytics_endpoint_returns_all_transactions(self, mock_database_service):
"""Test that the new analytics endpoint returns all transactions without pagination"""
# Mock data for 600 transactions
mock_transactions = []
for i in range(600):
mock_transactions.append({
"transactionId": f"txn-{i}",
"transactionDate": (datetime.now() - timedelta(days=i % 365)).isoformat(),
"description": f"Transaction {i}",
"transactionValue": 10.0 if i % 2 == 0 else -5.0,
"transactionCurrency": "EUR",
"transactionStatus": "booked",
"accountId": f"account-{i % 3}",
})
mock_database_service.get_transactions_from_db = AsyncMock(return_value=mock_transactions)
with patch('leggend.api.routes.transactions.database_service', mock_database_service):
app = create_app()
client = TestClient(app)
response = client.get("/api/v1/transactions/analytics?days=365")
assert response.status_code == 200
data = response.json()
# Verify that limit=None was passed to get all transactions
mock_database_service.get_transactions_from_db.assert_called_once()
call_args = mock_database_service.get_transactions_from_db.call_args
assert call_args.kwargs.get("limit") is None, "Analytics endpoint should pass limit=None"
# Verify that all 600 transactions are returned
assert data["success"] is True
transactions_data = data["data"]
assert len(transactions_data) == 600, "Analytics endpoint should return all 600 transactions"

View File

@@ -0,0 +1,170 @@
"""Integration tests for configurable paths."""
import pytest
import tempfile
import os
from pathlib import Path
from unittest.mock import patch
from leggen.utils.paths import path_manager
from leggen.database.sqlite import persist_balances, get_balances
class MockContext:
"""Mock context for testing."""
@pytest.mark.unit
class TestConfigurablePaths:
"""Test configurable path management."""
def test_default_paths(self):
"""Test that default paths are correctly set."""
# Reset path manager
original_config = path_manager._config_dir
original_db = path_manager._database_path
try:
path_manager._config_dir = None
path_manager._database_path = None
# Test defaults
config_dir = path_manager.get_config_dir()
db_path = path_manager.get_database_path()
assert config_dir == Path.home() / ".config" / "leggen"
assert db_path == Path.home() / ".config" / "leggen" / "leggen.db"
finally:
path_manager._config_dir = original_config
path_manager._database_path = original_db
def test_environment_variables(self):
"""Test that environment variables override defaults."""
with tempfile.TemporaryDirectory() as tmpdir:
test_config_dir = Path(tmpdir) / "test-config"
test_db_path = Path(tmpdir) / "test.db"
with patch.dict(
os.environ,
{
"LEGGEN_CONFIG_DIR": str(test_config_dir),
"LEGGEN_DATABASE_PATH": str(test_db_path),
},
):
# Reset path manager to pick up environment variables
original_config = path_manager._config_dir
original_db = path_manager._database_path
try:
path_manager._config_dir = None
path_manager._database_path = None
config_dir = path_manager.get_config_dir()
db_path = path_manager.get_database_path()
assert config_dir == test_config_dir
assert db_path == test_db_path
finally:
path_manager._config_dir = original_config
path_manager._database_path = original_db
def test_explicit_path_setting(self):
"""Test explicitly setting paths."""
with tempfile.TemporaryDirectory() as tmpdir:
test_config_dir = Path(tmpdir) / "explicit-config"
test_db_path = Path(tmpdir) / "explicit.db"
# Save original paths
original_config = path_manager._config_dir
original_db = path_manager._database_path
try:
# Set explicit paths
path_manager.set_config_dir(test_config_dir)
path_manager.set_database_path(test_db_path)
assert path_manager.get_config_dir() == test_config_dir
assert path_manager.get_database_path() == test_db_path
assert (
path_manager.get_config_file_path()
== test_config_dir / "config.toml"
)
assert (
path_manager.get_auth_file_path() == test_config_dir / "auth.json"
)
finally:
# Restore original paths
path_manager._config_dir = original_config
path_manager._database_path = original_db
def test_database_operations_with_custom_path(self):
"""Test that database operations work with custom paths."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp_file:
test_db_path = Path(tmp_file.name)
# Save original database path
original_db = path_manager._database_path
try:
# Set custom database path
path_manager.set_database_path(test_db_path)
# Test database operations
ctx = MockContext()
balance = {
"account_id": "test-account",
"bank": "TEST_BANK",
"status": "active",
"iban": "TEST_IBAN",
"amount": 1000.0,
"currency": "EUR",
"type": "available",
"timestamp": "2023-01-01T00:00:00",
}
# Persist balance
persist_balances(ctx, balance)
# Retrieve balances
balances = get_balances()
assert len(balances) == 1
assert balances[0]["account_id"] == "test-account"
assert balances[0]["amount"] == 1000.0
# Verify database file exists at custom location
assert test_db_path.exists()
finally:
# Restore original path and cleanup
path_manager._database_path = original_db
if test_db_path.exists():
test_db_path.unlink()
def test_directory_creation(self):
"""Test that directories are created as needed."""
with tempfile.TemporaryDirectory() as tmpdir:
test_config_dir = Path(tmpdir) / "new" / "config" / "dir"
test_db_path = Path(tmpdir) / "new" / "db" / "dir" / "test.db"
# Save original paths
original_config = path_manager._config_dir
original_db = path_manager._database_path
try:
# Set paths to non-existent directories
path_manager.set_config_dir(test_config_dir)
path_manager.set_database_path(test_db_path)
# Ensure directories are created
path_manager.ensure_config_dir_exists()
path_manager.ensure_database_dir_exists()
assert test_config_dir.exists()
assert test_db_path.parent.exists()
finally:
# Restore original paths
path_manager._config_dir = original_config
path_manager._database_path = original_db

View File

@@ -21,14 +21,18 @@ def temp_db_path():
@pytest.fixture
def mock_home_db_path(temp_db_path):
"""Mock the home database path to use temp file."""
config_dir = temp_db_path.parent / ".config" / "leggen"
config_dir.mkdir(parents=True, exist_ok=True)
db_file = config_dir / "leggen.db"
"""Mock the database path to use temp file."""
from leggen.utils.paths import path_manager
with patch("pathlib.Path.home") as mock_home:
mock_home.return_value = temp_db_path.parent
yield db_file
# Set the path manager to use the temporary database
original_database_path = path_manager._database_path
path_manager.set_database_path(temp_db_path)
try:
yield temp_db_path
finally:
# Restore original path
path_manager._database_path = original_database_path
@pytest.fixture
@@ -90,18 +94,14 @@ class TestSQLiteDatabase:
"""Test persisting transactions to database."""
ctx = MockContext()
# Mock the database path
with patch("pathlib.Path.home") as mock_home:
mock_home.return_value = mock_home_db_path.parent / ".."
# Persist transactions
new_transactions = sqlite_db.persist_transactions(
ctx, "test-account-123", sample_transactions
)
# Persist transactions
new_transactions = sqlite_db.persist_transactions(
ctx, "test-account-123", sample_transactions
)
# Should return all transactions as new
assert len(new_transactions) == 2
assert new_transactions[0]["internalTransactionId"] == "txn-001"
# Should return all transactions as new
assert len(new_transactions) == 2
assert new_transactions[0]["internalTransactionId"] == "txn-001"
def test_persist_transactions_duplicates(
self, mock_home_db_path, sample_transactions
@@ -109,40 +109,34 @@ class TestSQLiteDatabase:
"""Test handling duplicate transactions."""
ctx = MockContext()
with patch("pathlib.Path.home") as mock_home:
mock_home.return_value = mock_home_db_path.parent / ".."
# Insert transactions twice
new_transactions_1 = sqlite_db.persist_transactions(
ctx, "test-account-123", sample_transactions
)
new_transactions_2 = sqlite_db.persist_transactions(
ctx, "test-account-123", sample_transactions
)
# Insert transactions twice
new_transactions_1 = sqlite_db.persist_transactions(
ctx, "test-account-123", sample_transactions
)
new_transactions_2 = sqlite_db.persist_transactions(
ctx, "test-account-123", sample_transactions
)
# First time should return all as new
assert len(new_transactions_1) == 2
# Second time should also return all (INSERT OR REPLACE behavior with composite key)
assert len(new_transactions_2) == 2
# First time should return all as new
assert len(new_transactions_1) == 2
# Second time should also return all (INSERT OR REPLACE behavior with composite key)
assert len(new_transactions_2) == 2
def test_get_transactions_all(self, mock_home_db_path, sample_transactions):
"""Test retrieving all transactions."""
ctx = MockContext()
with patch("pathlib.Path.home") as mock_home:
mock_home.return_value = mock_home_db_path.parent / ".."
# Insert test data
sqlite_db.persist_transactions(ctx, "test-account-123", sample_transactions)
# Insert test data
sqlite_db.persist_transactions(ctx, "test-account-123", sample_transactions)
# Get all transactions
transactions = sqlite_db.get_transactions()
# Get all transactions
transactions = sqlite_db.get_transactions()
assert len(transactions) == 2
assert (
transactions[0]["internalTransactionId"] == "txn-002"
) # Ordered by date DESC
assert transactions[1]["internalTransactionId"] == "txn-001"
assert len(transactions) == 2
assert (
transactions[0]["internalTransactionId"] == "txn-002"
) # Ordered by date DESC
assert transactions[1]["internalTransactionId"] == "txn-001"
def test_get_transactions_filtered_by_account(
self, mock_home_db_path, sample_transactions

2
uv.lock generated
View File

@@ -220,7 +220,7 @@ wheels = [
[[package]]
name = "leggen"
version = "2025.9.7"
version = "2025.9.10"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },