mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 23:12:16 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b32853e8fd | ||
|
|
0750c41b7b | ||
|
|
1cd63731a3 | ||
|
|
38fddeb281 | ||
|
|
0205e5be0d | ||
|
|
ca7968cc3c | ||
|
|
e6da6ee9ab | ||
|
|
8802d24789 |
@@ -10,7 +10,6 @@ repos:
|
|||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
exclude: ".*\\.md$"
|
exclude: ".*\\.md$"
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: check-added-large-files
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ The command outputs instructions for setting the required environment variable t
|
|||||||
uv run leggen server
|
uv run leggen server
|
||||||
```
|
```
|
||||||
- For development mode with auto-reload: `uv run leggen server --reload`
|
- For development mode with auto-reload: `uv run leggen server --reload`
|
||||||
- API will be available at `http://localhost:8000` with docs at `http://localhost:8000/docs`
|
- API will be available at `http://localhost:8000` with docs at `http://localhost:8000/api/v1/docs`
|
||||||
|
|
||||||
### Start the Frontend
|
### Start the Frontend
|
||||||
1. Navigate to the frontend directory: `cd frontend`
|
1. Navigate to the frontend directory: `cd frontend`
|
||||||
|
|||||||
34
CHANGELOG.md
34
CHANGELOG.md
@@ -1,4 +1,38 @@
|
|||||||
|
|
||||||
|
## 2025.10.1 (2025/10/05)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **frontend:** Fix PWA caching system, remove prompts. ([1cd63731](https://github.com/elisiariocouto/leggen/commit/1cd63731a35a1c77a59d7ae1a898ad8f22e362e4))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Improve documentation, add gif showing web app. ([0750c41b](https://github.com/elisiariocouto/leggen/commit/0750c41b7b6634900ec19b1701d58b06346028e3))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **frontend:** Standardize button styling using shadcn Button component. ([38fddeb2](https://github.com/elisiariocouto/leggen/commit/38fddeb281588de41d8ff6292c1dd48443a059a4))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.10.0 (2025/10/01)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **gocardless:** Increase timeout to 30 seconds, some requests take some time. ([ca7968cc](https://github.com/elisiariocouto/leggen/commit/ca7968cc3c625e243fe2d75590a9e56f3100072b))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.26 (2025/09/30)
|
||||||
|
|
||||||
|
### Debug
|
||||||
|
|
||||||
|
- Log different sets of GoCardless rate limits. ([8802d247](https://github.com/elisiariocouto/leggen/commit/8802d24789cbb8e854d857a0d7cc89a25a26f378))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.25 (2025/09/30)
|
## 2025.9.25 (2025/09/30)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
280
README.md
280
README.md
@@ -1,13 +1,21 @@
|
|||||||
# 💲 leggen
|
# 💲 leggen
|
||||||
|
|
||||||
An Open Banking CLI and API service for managing bank connections and transactions.
|
|
||||||
|
|
||||||
This tool provides a **unified command-line interface** (`leggen`) with both CLI commands and an integrated **FastAPI backend service**, plus a **React Web Interface** to connect to banks using the GoCardless Open Banking API.
|
A self hosted Open Banking Dashboard, API and CLI for managing bank connections and transactions.
|
||||||
|
|
||||||
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
|
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## 🛠️ Technologies
|
## 🛠️ Technologies
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [React](https://reactjs.org/): Modern web interface with TypeScript
|
||||||
|
- [Vite](https://vitejs.dev/): Fast build tool and development server
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
|
||||||
|
- [shadcn/ui](https://ui.shadcn.com/): Modern component system built on Radix UI
|
||||||
|
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
|
||||||
|
|
||||||
### 🔌 API & Backend
|
### 🔌 API & Backend
|
||||||
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (integrated into `leggen server`)
|
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (integrated into `leggen server`)
|
||||||
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
|
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
|
||||||
@@ -16,12 +24,6 @@ Having your bank data accessible through both CLI and REST API gives you the pow
|
|||||||
### 📦 Storage
|
### 📦 Storage
|
||||||
- [SQLite](https://www.sqlite.org): for storing transactions, simple and easy to use
|
- [SQLite](https://www.sqlite.org): for storing transactions, simple and easy to use
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- [React](https://reactjs.org/): Modern web interface with TypeScript
|
|
||||||
- [Vite](https://vitejs.dev/): Fast build tool and development server
|
|
||||||
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
|
|
||||||
- [shadcn/ui](https://ui.shadcn.com/): Modern component system built on Radix UI
|
|
||||||
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
|
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
@@ -54,10 +56,9 @@ Having your bank data accessible through both CLI and REST API gives you the pow
|
|||||||
1. Create a GoCardless account at [https://gocardless.com/bank-account-data/](https://gocardless.com/bank-account-data/)
|
1. Create a GoCardless account at [https://gocardless.com/bank-account-data/](https://gocardless.com/bank-account-data/)
|
||||||
2. Get your API credentials (key and secret)
|
2. Get your API credentials (key and secret)
|
||||||
|
|
||||||
### Installation Options
|
### Installation
|
||||||
|
|
||||||
#### Option 1: Docker Compose (Recommended)
|
#### Docker Compose (Recommended)
|
||||||
The easiest way to get started is with Docker Compose, which includes both the React frontend and FastAPI backend:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
@@ -68,50 +69,11 @@ cd leggen
|
|||||||
mkdir -p data && cp config.example.toml data/config.toml
|
mkdir -p data && cp config.example.toml data/config.toml
|
||||||
# Edit data/config.toml with your GoCardless credentials
|
# Edit data/config.toml with your GoCardless credentials
|
||||||
|
|
||||||
# Start all services (frontend + backend)
|
# Start all services
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# Access the web interface at http://localhost:3000
|
# Access the web interface at http://localhost:3000
|
||||||
# API is available at http://localhost:8000
|
# API documentation at http://localhost:3000/api/v1/docs
|
||||||
```
|
|
||||||
|
|
||||||
#### Production Deployment
|
|
||||||
|
|
||||||
For production deployment using published Docker images:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone the repository
|
|
||||||
git clone https://github.com/elisiariocouto/leggen.git
|
|
||||||
cd leggen
|
|
||||||
|
|
||||||
# Create your configuration
|
|
||||||
mkdir -p data && cp config.example.toml data/config.toml
|
|
||||||
# Edit data/config.toml with your GoCardless credentials
|
|
||||||
|
|
||||||
# Start production services
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Access the web interface at http://localhost:3000
|
|
||||||
# API is available at http://localhost:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development vs Production
|
|
||||||
|
|
||||||
- **Development**: Use `docker compose -f compose.dev.yml up -d` (builds from source)
|
|
||||||
- **Production**: Use `docker compose up -d` (uses published images)
|
|
||||||
|
|
||||||
#### Option 2: Local Development
|
|
||||||
For development or local installation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install with uv (recommended) or pip
|
|
||||||
uv sync # or pip install -e .
|
|
||||||
|
|
||||||
# Start the API service
|
|
||||||
uv run leggen server --reload # Development mode with auto-reload
|
|
||||||
|
|
||||||
# Use the CLI (in another terminal)
|
|
||||||
uv run leggen --help
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
@@ -153,214 +115,22 @@ case_sensitive = ["SpecificStore"]
|
|||||||
|
|
||||||
## 📖 Usage
|
## 📖 Usage
|
||||||
|
|
||||||
### API Service (`leggen server`)
|
### Web Interface
|
||||||
|
Access the React web interface at `http://localhost:3000` after starting the services.
|
||||||
|
|
||||||
Start the FastAPI backend service:
|
### API Service
|
||||||
|
Visit `http://localhost:3000/api/v1/docs` for interactive API documentation.
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
```bash
|
```bash
|
||||||
# Production mode
|
leggen status # Check connection status
|
||||||
leggen server
|
leggen bank add # Connect to a new bank
|
||||||
|
leggen balances # View account balances
|
||||||
# Development mode with auto-reload
|
leggen transactions # List transactions
|
||||||
leggen server --reload
|
leggen sync # Trigger background sync
|
||||||
|
|
||||||
# Custom host and port
|
|
||||||
leggen server --host 127.0.0.1 --port 8080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
|
For more options, run `leggen --help` or `leggen <command> --help`.
|
||||||
|
|
||||||
### CLI Commands (`leggen`)
|
|
||||||
|
|
||||||
#### Basic Commands
|
|
||||||
```bash
|
|
||||||
# Check connection status
|
|
||||||
leggen status
|
|
||||||
|
|
||||||
# Connect to a new bank
|
|
||||||
leggen bank add
|
|
||||||
|
|
||||||
# View account balances
|
|
||||||
leggen balances
|
|
||||||
|
|
||||||
# List recent transactions
|
|
||||||
leggen transactions --limit 20
|
|
||||||
|
|
||||||
# View detailed transactions
|
|
||||||
leggen transactions --full
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Sync Operations
|
|
||||||
```bash
|
|
||||||
# Start background sync
|
|
||||||
leggen sync
|
|
||||||
|
|
||||||
# Synchronous sync (wait for completion)
|
|
||||||
leggen sync --wait
|
|
||||||
|
|
||||||
# Force sync (override running sync)
|
|
||||||
leggen sync --force --wait
|
|
||||||
```
|
|
||||||
|
|
||||||
#### API Integration
|
|
||||||
```bash
|
|
||||||
# Use custom API URL
|
|
||||||
leggen --api-url http://localhost:8080 status
|
|
||||||
|
|
||||||
# Set via environment variable
|
|
||||||
export LEGGEN_API_URL=http://localhost:8080
|
|
||||||
leggen status
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Usage
|
|
||||||
|
|
||||||
#### Development (build from source)
|
|
||||||
```bash
|
|
||||||
# Start development services
|
|
||||||
docker compose -f compose.dev.yml up -d
|
|
||||||
|
|
||||||
# View service status
|
|
||||||
docker compose -f compose.dev.yml ps
|
|
||||||
|
|
||||||
# Check logs
|
|
||||||
docker compose -f compose.dev.yml logs frontend
|
|
||||||
docker compose -f compose.dev.yml logs leggen-server
|
|
||||||
|
|
||||||
# Stop development services
|
|
||||||
docker compose -f compose.dev.yml down
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Production (use published images)
|
|
||||||
```bash
|
|
||||||
# Start production services
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# View service status
|
|
||||||
docker compose ps
|
|
||||||
|
|
||||||
# Check logs
|
|
||||||
docker compose logs frontend
|
|
||||||
docker compose logs leggen-server
|
|
||||||
|
|
||||||
# Access the web interface at http://localhost:3000
|
|
||||||
# API documentation at http://localhost:8000/docs
|
|
||||||
|
|
||||||
# Stop production services
|
|
||||||
docker compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔌 API Endpoints
|
|
||||||
|
|
||||||
The FastAPI backend provides comprehensive REST endpoints:
|
|
||||||
|
|
||||||
### Banks & Connections
|
|
||||||
- `GET /api/v1/banks/institutions?country=PT` - List available banks
|
|
||||||
- `POST /api/v1/banks/connect` - Create bank connection
|
|
||||||
- `GET /api/v1/banks/status` - Connection status
|
|
||||||
- `GET /api/v1/banks/countries` - Supported countries
|
|
||||||
|
|
||||||
### Accounts & Balances
|
|
||||||
- `GET /api/v1/accounts` - List all accounts
|
|
||||||
- `GET /api/v1/accounts/{id}` - Account details
|
|
||||||
- `GET /api/v1/accounts/{id}/balances` - Account balances
|
|
||||||
- `GET /api/v1/accounts/{id}/transactions` - Account transactions
|
|
||||||
|
|
||||||
### Transactions
|
|
||||||
- `GET /api/v1/transactions` - All transactions with filtering
|
|
||||||
- `GET /api/v1/transactions/stats` - Transaction statistics
|
|
||||||
|
|
||||||
### Sync & Scheduling
|
|
||||||
- `POST /api/v1/sync` - Trigger background sync
|
|
||||||
- `POST /api/v1/sync/now` - Synchronous sync
|
|
||||||
- `GET /api/v1/sync/status` - Sync status
|
|
||||||
- `GET/PUT /api/v1/sync/scheduler` - Scheduler configuration
|
|
||||||
|
|
||||||
### Notifications
|
|
||||||
- `GET/PUT /api/v1/notifications/settings` - Manage notifications
|
|
||||||
- `POST /api/v1/notifications/test` - Test notifications
|
|
||||||
|
|
||||||
## 🛠️ Development
|
|
||||||
|
|
||||||
### Local Development Setup
|
|
||||||
```bash
|
|
||||||
# Clone and setup
|
|
||||||
git clone https://github.com/elisiariocouto/leggen.git
|
|
||||||
cd leggen
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
uv sync
|
|
||||||
|
|
||||||
# Start API service with auto-reload
|
|
||||||
uv run leggen server --reload
|
|
||||||
|
|
||||||
# Use CLI commands
|
|
||||||
uv run leggen status
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
Run the comprehensive test suite with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
uv run pytest
|
|
||||||
|
|
||||||
# Run unit tests only
|
|
||||||
uv run pytest tests/unit/
|
|
||||||
|
|
||||||
# Run with verbose output
|
|
||||||
uv run pytest tests/unit/ -v
|
|
||||||
|
|
||||||
# Run specific test files
|
|
||||||
uv run pytest tests/unit/test_config.py -v
|
|
||||||
uv run pytest tests/unit/test_scheduler.py -v
|
|
||||||
uv run pytest tests/unit/test_api_banks.py -v
|
|
||||||
|
|
||||||
# Run tests by markers
|
|
||||||
uv run pytest -m unit # Unit tests
|
|
||||||
uv run pytest -m api # API endpoint tests
|
|
||||||
uv run pytest -m cli # CLI tests
|
|
||||||
```
|
|
||||||
|
|
||||||
The test suite includes:
|
|
||||||
- **Configuration management tests** - TOML config loading/saving
|
|
||||||
- **API endpoint tests** - FastAPI route testing with mocked dependencies
|
|
||||||
- **CLI API client tests** - HTTP client integration testing
|
|
||||||
- **Background scheduler tests** - APScheduler job management
|
|
||||||
- **Mock data and fixtures** - Realistic test data for banks, accounts, transactions
|
|
||||||
|
|
||||||
### Code Structure
|
|
||||||
```
|
|
||||||
leggen/ # CLI application
|
|
||||||
├── commands/ # CLI command implementations
|
|
||||||
├── utils/ # Shared utilities
|
|
||||||
├── api/ # FastAPI API routes and models
|
|
||||||
├── services/ # Business logic
|
|
||||||
├── background/ # Background job scheduler
|
|
||||||
└── api_client.py # API client for server communication
|
|
||||||
|
|
||||||
tests/ # Test suite
|
|
||||||
├── conftest.py # Shared test fixtures
|
|
||||||
└── unit/ # Unit tests
|
|
||||||
├── test_config.py # Configuration tests
|
|
||||||
├── test_scheduler.py # Background scheduler tests
|
|
||||||
├── test_api_banks.py # Banks API tests
|
|
||||||
├── test_api_accounts.py # Accounts API tests
|
|
||||||
└── test_api_client.py # CLI API client tests
|
|
||||||
```
|
|
||||||
|
|
||||||
### Contributing
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch
|
|
||||||
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
|
## ⚠️ Notes
|
||||||
- This project is in active development
|
- This project is in active development
|
||||||
- GoCardless API rate limits apply
|
|
||||||
- Some banks may require additional authorization steps
|
|
||||||
- Docker images are automatically built and published on releases
|
|
||||||
|
|||||||
BIN
docs/leggen_demo.gif
Normal file
BIN
docs/leggen_demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 548 KiB |
@@ -1,4 +1,9 @@
|
|||||||
server {
|
server {
|
||||||
|
|
||||||
|
types {
|
||||||
|
application/manifest+json webmanifest;
|
||||||
|
}
|
||||||
|
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
@@ -13,6 +18,9 @@ server {
|
|||||||
|
|
||||||
# Handle client-side routing
|
# Handle client-side routing
|
||||||
location / {
|
location / {
|
||||||
|
autoindex off;
|
||||||
|
expires off;
|
||||||
|
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +34,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Cache static assets
|
# Cache static assets
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
location ~* \.(css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,24 +234,28 @@ export default function AccountSettings() {
|
|||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
disabled={
|
disabled={
|
||||||
!editingName.trim() ||
|
!editingName.trim() ||
|
||||||
updateAccountMutation.isPending
|
updateAccountMutation.isPending
|
||||||
}
|
}
|
||||||
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100"
|
||||||
title="Save changes"
|
title="Save changes"
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={handleEditCancel}
|
onClick={handleEditCancel}
|
||||||
className="p-1 text-gray-600 hover:text-gray-700"
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8"
|
||||||
title="Cancel editing"
|
title="Cancel editing"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
{account.institution_id}
|
{account.institution_id}
|
||||||
@@ -265,13 +269,15 @@ export default function AccountSettings() {
|
|||||||
account.name ||
|
account.name ||
|
||||||
"Unnamed Account"}
|
"Unnamed Account"}
|
||||||
</h4>
|
</h4>
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleEditStart(account)}
|
onClick={() => handleEditStart(account)}
|
||||||
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 flex-shrink-0"
|
||||||
title="Edit account name"
|
title="Edit account name"
|
||||||
>
|
>
|
||||||
<Edit2 className="h-4 w-4" />
|
<Edit2 className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
{account.institution_id}
|
{account.institution_id}
|
||||||
|
|||||||
@@ -273,24 +273,28 @@ export default function AccountsOverview() {
|
|||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
disabled={
|
disabled={
|
||||||
!editingName.trim() ||
|
!editingName.trim() ||
|
||||||
updateAccountMutation.isPending
|
updateAccountMutation.isPending
|
||||||
}
|
}
|
||||||
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100"
|
||||||
title="Save changes"
|
title="Save changes"
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={handleEditCancel}
|
onClick={handleEditCancel}
|
||||||
className="p-1 text-gray-600 hover:text-gray-700"
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8"
|
||||||
title="Cancel editing"
|
title="Cancel editing"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
{account.institution_id}
|
{account.institution_id}
|
||||||
@@ -304,13 +308,15 @@ export default function AccountsOverview() {
|
|||||||
account.name ||
|
account.name ||
|
||||||
"Unnamed Account"}
|
"Unnamed Account"}
|
||||||
</h4>
|
</h4>
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleEditStart(account)}
|
onClick={() => handleEditStart(account)}
|
||||||
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 flex-shrink-0"
|
||||||
title="Edit account name"
|
title="Edit account name"
|
||||||
>
|
>
|
||||||
<Edit2 className="h-4 w-4" />
|
<Edit2 className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
{account.institution_id}
|
{account.institution_id}
|
||||||
|
|||||||
@@ -166,13 +166,15 @@ export default function NotificationFiltersDrawer({
|
|||||||
className="flex items-center space-x-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
|
className="flex items-center space-x-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
|
||||||
>
|
>
|
||||||
<span>{filter}</span>
|
<span>{filter}</span>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeCaseInsensitiveFilter(index)}
|
onClick={() => removeCaseInsensitiveFilter(index)}
|
||||||
className="text-secondary-foreground hover:text-foreground"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 hover:bg-secondary-foreground/10"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -222,13 +224,15 @@ export default function NotificationFiltersDrawer({
|
|||||||
className="flex items-center space-x-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
|
className="flex items-center space-x-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
|
||||||
>
|
>
|
||||||
<span>{filter}</span>
|
<span>{filter}</span>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeCaseSensitiveFilter(index)}
|
onClick={() => removeCaseSensitiveFilter(index)}
|
||||||
className="text-secondary-foreground hover:text-foreground"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 hover:bg-secondary-foreground/10"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { X, Download, RotateCcw } from "lucide-react";
|
|
||||||
|
|
||||||
interface BeforeInstallPromptEvent extends Event {
|
|
||||||
prompt(): Promise<void>;
|
|
||||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PWAPromptProps {
|
|
||||||
onInstall?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PWAInstallPrompt({ onInstall }: PWAPromptProps) {
|
|
||||||
const [deferredPrompt, setDeferredPrompt] =
|
|
||||||
useState<BeforeInstallPromptEvent | null>(null);
|
|
||||||
const [showPrompt, setShowPrompt] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: Event) => {
|
|
||||||
// Prevent the mini-infobar from appearing on mobile
|
|
||||||
e.preventDefault();
|
|
||||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
|
||||||
setShowPrompt(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("beforeinstallprompt", handler);
|
|
||||||
|
|
||||||
return () => window.removeEventListener("beforeinstallprompt", handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleInstall = async () => {
|
|
||||||
if (!deferredPrompt) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deferredPrompt.prompt();
|
|
||||||
const { outcome } = await deferredPrompt.userChoice;
|
|
||||||
|
|
||||||
if (outcome === "accepted") {
|
|
||||||
onInstall?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
setDeferredPrompt(null);
|
|
||||||
setShowPrompt(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error installing PWA:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDismiss = () => {
|
|
||||||
setShowPrompt(false);
|
|
||||||
setDeferredPrompt(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!showPrompt || !deferredPrompt) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Download className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Install Leggen
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Add to your home screen for quick access
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleDismiss}
|
|
||||||
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleInstall}
|
|
||||||
className="flex-1 bg-blue-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
Install
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDismiss}
|
|
||||||
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
|
||||||
>
|
|
||||||
Not now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PWAUpdatePromptProps {
|
|
||||||
updateAvailable: boolean;
|
|
||||||
onUpdate: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PWAUpdatePrompt({
|
|
||||||
updateAvailable,
|
|
||||||
onUpdate,
|
|
||||||
}: PWAUpdatePromptProps) {
|
|
||||||
const [showPrompt, setShowPrompt] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (updateAvailable) {
|
|
||||||
setShowPrompt(true);
|
|
||||||
}
|
|
||||||
}, [updateAvailable]);
|
|
||||||
|
|
||||||
const handleUpdate = () => {
|
|
||||||
onUpdate();
|
|
||||||
setShowPrompt(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDismiss = () => {
|
|
||||||
setShowPrompt(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!showPrompt || !updateAvailable) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed top-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<RotateCcw className="h-5 w-5 text-green-600 dark:text-green-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Update Available
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
A new version of Leggen is ready to install
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleDismiss}
|
|
||||||
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleUpdate}
|
|
||||||
className="flex-1 bg-green-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
||||||
>
|
|
||||||
Update Now
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDismiss}
|
|
||||||
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
|
||||||
>
|
|
||||||
Later
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -403,24 +403,28 @@ export default function Settings() {
|
|||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
disabled={
|
disabled={
|
||||||
!editingName.trim() ||
|
!editingName.trim() ||
|
||||||
updateAccountMutation.isPending
|
updateAccountMutation.isPending
|
||||||
}
|
}
|
||||||
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100"
|
||||||
title="Save changes"
|
title="Save changes"
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={handleEditCancel}
|
onClick={handleEditCancel}
|
||||||
className="p-1 text-gray-600 hover:text-gray-700"
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8"
|
||||||
title="Cancel editing"
|
title="Cancel editing"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
{account.institution_id}
|
{account.institution_id}
|
||||||
@@ -434,13 +438,15 @@ export default function Settings() {
|
|||||||
account.name ||
|
account.name ||
|
||||||
"Unnamed Account"}
|
"Unnamed Account"}
|
||||||
</h4>
|
</h4>
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleEditStart(account)}
|
onClick={() => handleEditStart(account)}
|
||||||
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 flex-shrink-0"
|
||||||
title="Edit account name"
|
title="Edit account name"
|
||||||
>
|
>
|
||||||
<Edit2 className="h-4 w-4" />
|
<Edit2 className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
{account.institution_id}
|
{account.institution_id}
|
||||||
@@ -579,7 +585,7 @@ export default function Settings() {
|
|||||||
Created {formatDate(connection.created_at)}
|
Created {formatDate(connection.created_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const isWorking =
|
const isWorking =
|
||||||
connection.status.toLowerCase() === "ln";
|
connection.status.toLowerCase() === "ln";
|
||||||
@@ -594,11 +600,13 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={deleteBankConnectionMutation.isPending}
|
disabled={deleteBankConnectionMutation.isPending}
|
||||||
className="p-1 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
title="Delete connection"
|
title="Delete connection"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -259,14 +259,15 @@ export default function TransactionsTable() {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const transaction = row.original;
|
const transaction = row.original;
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleViewRaw(transaction)}
|
onClick={() => handleViewRaw(transaction)}
|
||||||
className="inline-flex items-center px-2 py-1 text-xs bg-muted text-muted-foreground rounded hover:bg-accent transition-colors"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
title="View raw transaction data"
|
title="View raw transaction data"
|
||||||
>
|
>
|
||||||
<Eye className="h-3 w-3 mr-1" />
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
Raw
|
Raw
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -530,14 +531,15 @@ export default function TransactionsTable() {
|
|||||||
transaction.transaction_currency,
|
transaction.transaction_currency,
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleViewRaw(transaction)}
|
onClick={() => handleViewRaw(transaction)}
|
||||||
className="inline-flex items-center px-2 py-1 text-xs bg-muted text-muted-foreground rounded hover:bg-accent transition-colors"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
title="View raw transaction data"
|
title="View raw transaction data"
|
||||||
>
|
>
|
||||||
<Eye className="h-3 w-3 mr-1" />
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
Raw
|
Raw
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
interface PWAUpdate {
|
|
||||||
updateAvailable: boolean;
|
|
||||||
updateSW: () => Promise<void>;
|
|
||||||
forceReload: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePWA(): PWAUpdate {
|
|
||||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
|
||||||
const [updateSW, setUpdateSW] = useState<() => Promise<void>>(
|
|
||||||
() => async () => {},
|
|
||||||
);
|
|
||||||
|
|
||||||
const forceReload = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Clear all caches
|
|
||||||
if ("caches" in window) {
|
|
||||||
const cacheNames = await caches.keys();
|
|
||||||
await Promise.all(
|
|
||||||
cacheNames.map((cacheName) => caches.delete(cacheName)),
|
|
||||||
);
|
|
||||||
console.log("All caches cleared");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unregister service worker
|
|
||||||
if ("serviceWorker" in navigator) {
|
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
||||||
await Promise.all(
|
|
||||||
registrations.map((registration) => registration.unregister()),
|
|
||||||
);
|
|
||||||
console.log("All service workers unregistered");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force reload
|
|
||||||
window.location.reload();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error during force reload:", error);
|
|
||||||
// Fallback: just reload the page
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Check if SW registration is available
|
|
||||||
if ("serviceWorker" in navigator) {
|
|
||||||
// Import the registerSW function
|
|
||||||
import("virtual:pwa-register")
|
|
||||||
.then(({ registerSW }) => {
|
|
||||||
const updateSWFunction = registerSW({
|
|
||||||
onNeedRefresh() {
|
|
||||||
setUpdateAvailable(true);
|
|
||||||
setUpdateSW(() => updateSWFunction);
|
|
||||||
},
|
|
||||||
onOfflineReady() {
|
|
||||||
console.log("App ready to work offline");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// PWA not available in development mode or when disabled
|
|
||||||
console.log("PWA registration not available");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
updateAvailable,
|
|
||||||
updateSW,
|
|
||||||
forceReload,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { apiClient } from "../lib/api";
|
|
||||||
|
|
||||||
const VERSION_STORAGE_KEY = "leggen_app_version";
|
|
||||||
|
|
||||||
export function useVersionCheck(forceReload: () => Promise<void>) {
|
|
||||||
const { data: healthStatus, isSuccess: healthSuccess } = useQuery({
|
|
||||||
queryKey: ["health"],
|
|
||||||
queryFn: apiClient.getHealth,
|
|
||||||
refetchInterval: 30000,
|
|
||||||
retry: false,
|
|
||||||
staleTime: 0, // Always consider data stale to ensure fresh version checks
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (healthSuccess && healthStatus?.version) {
|
|
||||||
const currentVersion = healthStatus.version;
|
|
||||||
const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);
|
|
||||||
|
|
||||||
if (storedVersion && storedVersion !== currentVersion) {
|
|
||||||
console.log(
|
|
||||||
`Version mismatch detected: stored=${storedVersion}, current=${currentVersion}`,
|
|
||||||
);
|
|
||||||
console.log("Clearing cache and reloading...");
|
|
||||||
|
|
||||||
// Update stored version first
|
|
||||||
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
|
|
||||||
|
|
||||||
// Force reload to clear cache
|
|
||||||
forceReload();
|
|
||||||
} else if (!storedVersion) {
|
|
||||||
// First time loading, store the version
|
|
||||||
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
|
|
||||||
console.log(`Version stored: ${currentVersion}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [healthSuccess, healthStatus?.version, forceReload]);
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
import { registerSW } from "virtual:pwa-register";
|
||||||
|
|
||||||
const router = createRouter({ routeTree });
|
const router = createRouter({ routeTree });
|
||||||
|
|
||||||
@@ -17,6 +18,57 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const intervalMS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
registerSW({
|
||||||
|
onRegisteredSW(swUrl, r) {
|
||||||
|
console.log("[PWA] Service worker registered successfully");
|
||||||
|
|
||||||
|
if (r) {
|
||||||
|
setInterval(async () => {
|
||||||
|
console.log("[PWA] Checking for updates...");
|
||||||
|
|
||||||
|
if (r.installing) {
|
||||||
|
console.log("[PWA] Update already installing, skipping check");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!navigator) {
|
||||||
|
console.log("[PWA] Navigator not available, skipping check");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("connection" in navigator && !navigator.onLine) {
|
||||||
|
console.log("[PWA] Device is offline, skipping check");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(swUrl, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
cache: "no-store",
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp?.status === 200) {
|
||||||
|
console.log("[PWA] Update check successful, triggering update");
|
||||||
|
await r.update();
|
||||||
|
} else {
|
||||||
|
console.log(`[PWA] Update check returned status: ${resp?.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[PWA] Error checking for updates:", error);
|
||||||
|
}
|
||||||
|
}, intervalMS);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOfflineReady() {
|
||||||
|
console.log("[PWA] App ready to work offline");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -1,31 +1,10 @@
|
|||||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||||
import { AppSidebar } from "../components/AppSidebar";
|
import { AppSidebar } from "../components/AppSidebar";
|
||||||
import { SiteHeader } from "../components/SiteHeader";
|
import { SiteHeader } from "../components/SiteHeader";
|
||||||
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
|
|
||||||
import { usePWA } from "../hooks/usePWA";
|
|
||||||
import { useVersionCheck } from "../hooks/useVersionCheck";
|
|
||||||
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const { updateAvailable, updateSW, forceReload } = usePWA();
|
|
||||||
|
|
||||||
// Check for version mismatches and force reload if needed
|
|
||||||
useVersionCheck(forceReload);
|
|
||||||
|
|
||||||
const handlePWAInstall = () => {
|
|
||||||
console.log("PWA installed successfully");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePWAUpdate = async () => {
|
|
||||||
try {
|
|
||||||
await updateSW();
|
|
||||||
console.log("PWA updated successfully");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating PWA:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider
|
<SidebarProvider
|
||||||
style={
|
style={
|
||||||
@@ -43,13 +22,6 @@ function RootLayout() {
|
|||||||
</main>
|
</main>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
|
||||||
{/* PWA Prompts */}
|
|
||||||
<PWAInstallPrompt onInstall={handlePWAInstall} />
|
|
||||||
<PWAUpdatePrompt
|
|
||||||
updateAvailable={updateAvailable}
|
|
||||||
onUpdate={handlePWAUpdate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Toast Notifications */}
|
{/* Toast Notifications */}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ export default defineConfig({
|
|||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: "autoUpdate",
|
registerType: "autoUpdate",
|
||||||
includeAssets: [
|
includeAssets: [
|
||||||
"favicon.ico",
|
"robots.txt"
|
||||||
"apple-touch-icon-180x180.png",
|
|
||||||
"maskable-icon-512x512.png",
|
|
||||||
"robots.txt",
|
|
||||||
],
|
],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: "Leggen",
|
name: "Leggen",
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ def create_app() -> FastAPI:
|
|||||||
description="Open Banking API for Leggen",
|
description="Open Banking API for Leggen",
|
||||||
version=version,
|
version=version,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
docs_url="/api/v1/docs",
|
||||||
|
openapi_url="/api/v1/openapi.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add CORS middleware
|
# Add CORS middleware
|
||||||
|
|||||||
@@ -11,17 +11,18 @@ from leggen.utils.paths import path_manager
|
|||||||
|
|
||||||
def _log_rate_limits(response, method, url):
|
def _log_rate_limits(response, method, url):
|
||||||
"""Log GoCardless API rate limit headers"""
|
"""Log GoCardless API rate limit headers"""
|
||||||
limit = response.headers.get("http_x_ratelimit_limit") or response.headers.get(
|
limit = response.headers.get("http_x_ratelimit_limit")
|
||||||
"http_x_ratelimit_account_success_limit"
|
remaining = response.headers.get("http_x_ratelimit_remaining")
|
||||||
)
|
reset = response.headers.get("http_x_ratelimit_reset")
|
||||||
remaining = response.headers.get(
|
|
||||||
"http_x_ratelimit_remaining"
|
account_limit = response.headers.get("http_x_ratelimit_account_success_limit")
|
||||||
) or response.headers.get("http_x_ratelimit_account_success_remaining")
|
account_remaining = response.headers.get(
|
||||||
reset = response.headers.get("http_x_ratelimit_reset") or response.headers.get(
|
"http_x_ratelimit_account_success_remaining"
|
||||||
"http_x_ratelimit_account_success_reset"
|
|
||||||
)
|
)
|
||||||
|
account_reset = response.headers.get("http_x_ratelimit_account_success_reset")
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{method} {url} - Limit: {limit}, Remaining: {remaining}, Reset: {reset}s"
|
f"{method} {url} Limit/Remaining/Reset (Global: {limit}/{remaining}/{reset}s) (Account: {account_limit}/{account_remaining}/{account_reset}s)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +41,9 @@ class GoCardlessService:
|
|||||||
headers = await self._get_auth_headers()
|
headers = await self._get_auth_headers()
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.request(method, url, headers=headers, **kwargs)
|
response = await client.request(
|
||||||
|
method, url, headers=headers, timeout=30, **kwargs
|
||||||
|
)
|
||||||
_log_rate_limits(response, method, url)
|
_log_rate_limits(response, method, url)
|
||||||
|
|
||||||
# If we get 401, clear token cache and retry once
|
# If we get 401, clear token cache and retry once
|
||||||
@@ -48,7 +51,9 @@ class GoCardlessService:
|
|||||||
logger.warning("Got 401, clearing token cache and retrying")
|
logger.warning("Got 401, clearing token cache and retrying")
|
||||||
self._token = None
|
self._token = None
|
||||||
headers = await self._get_auth_headers()
|
headers = await self._get_auth_headers()
|
||||||
response = await client.request(method, url, headers=headers, **kwargs)
|
response = await client.request(
|
||||||
|
method, url, headers=headers, timeout=30, **kwargs
|
||||||
|
)
|
||||||
_log_rate_limits(response, method, url)
|
_log_rate_limits(response, method, url)
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.25"
|
version = "2025.10.1"
|
||||||
description = "An Open Banking CLI"
|
description = "An Open Banking CLI"
|
||||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||||
requires-python = "~=3.13.0"
|
requires-python = "~=3.13.0"
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -257,7 +257,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.25"
|
version = "2025.10.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
|
|||||||
Reference in New Issue
Block a user