Compare commits

...

6 Commits

Author SHA1 Message Date
Elisiário Couto
b32853e8fd chore(ci): Bump version to 2025.10.1 2025-10-06 00:06:07 +01:00
Elisiário Couto
0750c41b7b docs: Improve documentation, add gif showing web app. 2025-10-06 00:02:56 +01:00
Elisiário Couto
1cd63731a3 fix(frontend): Fix PWA caching system, remove prompts. 2025-10-05 23:15:29 +01:00
Elisiário Couto
38fddeb281 refactor(frontend): Standardize button styling using shadcn Button component.
Replace inconsistent native HTML button elements with shadcn/ui Button
component across all components for consistent styling and behavior.

Changes:
- TransactionsTable: Use Button with ghost variant for Raw action buttons
- Settings: Standardize edit, save, cancel, and delete buttons with icon variant
- AccountsOverview: Apply consistent Button component for account actions
- AccountSettings: Update account editing buttons to use Button component
- NotificationFiltersDrawer: Convert filter removal buttons to Button component

Benefits:
- Consistent design system throughout the app
- Better accessibility and keyboard navigation
- Proper theme support and state handling
- Reduced custom CSS and improved maintainability
2025-10-05 23:14:19 +01:00
Elisiário Couto
0205e5be0d chore(ci): Bump version to 2025.10.0 2025-10-01 11:30:48 +01:00
Elisiário Couto
ca7968cc3c fix(gocardless): Increase timeout to 30 seconds, some requests take some time. 2025-10-01 11:05:52 +01:00
21 changed files with 192 additions and 607 deletions

View File

@@ -10,7 +10,6 @@ repos:
- id: trailing-whitespace
exclude: ".*\\.md$"
- id: end-of-file-fixer
- id: check-added-large-files
- repo: local
hooks:
- id: mypy

View File

@@ -41,7 +41,7 @@ The command outputs instructions for setting the required environment variable t
uv run leggen server
```
- 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
1. Navigate to the frontend directory: `cd frontend`

View File

@@ -1,4 +1,30 @@
## 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

280
README.md
View File

@@ -1,13 +1,21 @@
# 💲 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.
![Leggen demo](docs/leggen_demo.gif)
## 🛠️ 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
- [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
@@ -16,12 +24,6 @@ Having your bank data accessible through both CLI and REST API gives you the pow
### 📦 Storage
- [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
@@ -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/)
2. Get your API credentials (key and secret)
### Installation Options
### Installation
#### Option 1: Docker Compose (Recommended)
The easiest way to get started is with Docker Compose, which includes both the React frontend and FastAPI backend:
#### Docker Compose (Recommended)
```bash
# Clone the repository
@@ -68,50 +69,11 @@ cd leggen
mkdir -p data && cp config.example.toml data/config.toml
# Edit data/config.toml with your GoCardless credentials
# Start all services (frontend + backend)
# Start all services
docker compose up -d
# Access the web interface at http://localhost:3000
# API is available at http://localhost:8000
```
#### 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
# API documentation at http://localhost:3000/api/v1/docs
```
### Configuration
@@ -153,214 +115,22 @@ case_sensitive = ["SpecificStore"]
## 📖 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
# Production mode
leggen server
# Development mode with auto-reload
leggen server --reload
# Custom host and port
leggen server --host 127.0.0.1 --port 8080
leggen status # Check connection status
leggen bank add # Connect to a new bank
leggen balances # View account balances
leggen transactions # List transactions
leggen sync # Trigger background sync
```
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
### 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
For more options, run `leggen --help` or `leggen <command> --help`.
## ⚠️ Notes
- 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

View File

@@ -1,4 +1,9 @@
server {
types {
application/manifest+json webmanifest;
}
listen 80;
server_name localhost;
root /usr/share/nginx/html;
@@ -13,6 +18,9 @@ server {
# Handle client-side routing
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;
}
@@ -26,7 +34,7 @@ server {
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
location ~* \.(css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}

View File

@@ -234,24 +234,28 @@ export default function AccountSettings() {
}}
autoFocus
/>
<button
<Button
onClick={handleEditSave}
disabled={
!editingName.trim() ||
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"
>
<Check className="h-4 w-4" />
</button>
<button
</Button>
<Button
onClick={handleEditCancel}
className="p-1 text-gray-600 hover:text-gray-700"
size="icon"
variant="ghost"
className="h-8 w-8"
title="Cancel editing"
>
<X className="h-4 w-4" />
</button>
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
@@ -265,13 +269,15 @@ export default function AccountSettings() {
account.name ||
"Unnamed Account"}
</h4>
<button
<Button
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"
>
<Edit2 className="h-4 w-4" />
</button>
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}

View File

@@ -273,24 +273,28 @@ export default function AccountsOverview() {
}}
autoFocus
/>
<button
<Button
onClick={handleEditSave}
disabled={
!editingName.trim() ||
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"
>
<Check className="h-4 w-4" />
</button>
<button
</Button>
<Button
onClick={handleEditCancel}
className="p-1 text-gray-600 hover:text-gray-700"
size="icon"
variant="ghost"
className="h-8 w-8"
title="Cancel editing"
>
<X className="h-4 w-4" />
</button>
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
@@ -304,13 +308,15 @@ export default function AccountsOverview() {
account.name ||
"Unnamed Account"}
</h4>
<button
<Button
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"
>
<Edit2 className="h-4 w-4" />
</button>
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}

View File

@@ -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"
>
<span>{filter}</span>
<button
<Button
type="button"
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" />
</button>
</Button>
</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"
>
<span>{filter}</span>
<button
<Button
type="button"
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" />
</button>
</Button>
</div>
))
) : (

View File

@@ -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>
);
}

View File

@@ -403,24 +403,28 @@ export default function Settings() {
}}
autoFocus
/>
<button
<Button
onClick={handleEditSave}
disabled={
!editingName.trim() ||
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"
>
<Check className="h-4 w-4" />
</button>
<button
</Button>
<Button
onClick={handleEditCancel}
className="p-1 text-gray-600 hover:text-gray-700"
size="icon"
variant="ghost"
className="h-8 w-8"
title="Cancel editing"
>
<X className="h-4 w-4" />
</button>
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
@@ -434,13 +438,15 @@ export default function Settings() {
account.name ||
"Unnamed Account"}
</h4>
<button
<Button
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"
>
<Edit2 className="h-4 w-4" />
</button>
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
@@ -579,7 +585,7 @@ export default function Settings() {
Created {formatDate(connection.created_at)}
</p>
</div>
<button
<Button
onClick={() => {
const isWorking =
connection.status.toLowerCase() === "ln";
@@ -594,11 +600,13 @@ export default function Settings() {
}
}}
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"
>
<Trash2 className="h-4 w-4" />
</button>
</Button>
</div>
</div>
</div>

View File

@@ -259,14 +259,15 @@ export default function TransactionsTable() {
cell: ({ row }) => {
const transaction = row.original;
return (
<button
<Button
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"
>
<Eye className="h-3 w-3 mr-1" />
Raw
</button>
</Button>
);
},
},
@@ -530,14 +531,15 @@ export default function TransactionsTable() {
transaction.transaction_currency,
)}
</p>
<button
<Button
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"
>
<Eye className="h-3 w-3 mr-1" />
Raw
</button>
</Button>
</div>
</div>
</div>

View File

@@ -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,
};
}

View File

@@ -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]);
}

View File

@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "./contexts/ThemeContext";
import "./index.css";
import { routeTree } from "./routeTree.gen";
import { registerSW } from "virtual:pwa-register";
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(
<StrictMode>
<QueryClientProvider client={queryClient}>

View File

@@ -1,31 +1,10 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { AppSidebar } from "../components/AppSidebar";
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 { Toaster } from "../components/ui/sonner";
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 (
<SidebarProvider
style={
@@ -43,13 +22,6 @@ function RootLayout() {
</main>
</SidebarInset>
{/* PWA Prompts */}
<PWAInstallPrompt onInstall={handlePWAInstall} />
<PWAUpdatePrompt
updateAvailable={updateAvailable}
onUpdate={handlePWAUpdate}
/>
{/* Toast Notifications */}
<Toaster />
</SidebarProvider>

View File

@@ -11,10 +11,7 @@ export default defineConfig({
VitePWA({
registerType: "autoUpdate",
includeAssets: [
"favicon.ico",
"apple-touch-icon-180x180.png",
"maskable-icon-512x512.png",
"robots.txt",
"robots.txt"
],
manifest: {
name: "Leggen",

View File

@@ -60,6 +60,8 @@ def create_app() -> FastAPI:
description="Open Banking API for Leggen",
version=version,
lifespan=lifespan,
docs_url="/api/v1/docs",
openapi_url="/api/v1/openapi.json",
)
# Add CORS middleware

View File

@@ -41,7 +41,9 @@ class GoCardlessService:
headers = await self._get_auth_headers()
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)
# If we get 401, clear token cache and retry once
@@ -49,7 +51,9 @@ class GoCardlessService:
logger.warning("Got 401, clearing token cache and retrying")
self._token = None
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)
response.raise_for_status()

View File

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

2
uv.lock generated
View File

@@ -257,7 +257,7 @@ wheels = [
[[package]]
name = "leggen"
version = "2025.9.26"
version = "2025.10.1"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },