Compare commits

..

11 Commits

Author SHA1 Message Date
Elisiário Couto
02c4f5c6ef chore(ci): Bump version to 2025.9.14 2025-09-18 11:49:36 +01:00
Elisiário Couto
30d7c2ed4e chore(ci): Prevent double GitHub Actions runs on new releases. 2025-09-18 11:21:04 +01:00
Elisiário Couto
61442a598f fix(config): Remove aliases for configuration keys that were disabling telegram notifications in some cases. 2025-09-18 11:09:43 +01:00
Elisiário Couto
b7da446fa5 chore(ci): Bump version to 2025.9.13 2025-09-17 23:29:02 +01:00
Elisiário Couto
5a626b5394 chore: Enable browsermcp and shadcn MCP servers. 2025-09-17 23:27:14 +01:00
Elisiário Couto
d9a39c30ab feat(frontend): Update analytics cards to match home page design consistency.
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 22:23:34 +01:00
Elisiário Couto
155a48d7dc fix(frontend): Remove broken running balance feature in transactions table. 2025-09-17 22:16:13 +01:00
Elisiário Couto
8ab760815c fix(frontend): Resolve dual scroll and excessive whitespace issues on transactions page.
- Change root layout from h-screen to min-h-screen to prevent height conflicts
- Remove overflow-hidden and overflow-y-auto from main container to eliminate competing scroll contexts
- Streamline TransactionsTable layout by removing unnecessary overflow wrappers
- Add max-w-full constraint to prevent horizontal overflow issues

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 22:11:03 +01:00
Elisiário Couto
2825dba2e9 feat(frontend): Update brand identity with new logo and color scheme.
- Add new Logo component with gradient design (blue #0b74de to cyan #06b6d4)
- Replace CreditCard icon with custom Logo in sidebar
- Update primary and secondary theme colors to match brand gradient
- Regenerate all PWA icons with new logo design
- Update theme colors in PWA manifest and meta tags
- Fix ESLint config to ignore generated PWA files

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 21:58:46 +01:00
copilot-swe-agent[bot]
3049a8cd2f feat(frontend): Add PWA install prompts, update notifications, and app shortcuts
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-17 21:58:46 +01:00
copilot-swe-agent[bot]
86891441d6 feat(frontend): Add comprehensive PWA capabilities with dynamic theme support
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-17 21:58:46 +01:00
45 changed files with 5754 additions and 530 deletions

View File

@@ -1,22 +0,0 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(uv sync:*)",
"Bash(uv run pytest:*)",
"Bash(git commit:*)",
"Bash(ruff check:*)",
"Bash(git add:*)",
"Bash(mypy:*)",
"WebFetch(domain:localhost)",
"Bash(npm create:*)",
"Bash(npm install)",
"Bash(npm install:*)",
"Bash(npx tailwindcss init:*)",
"Bash(./node_modules/.bin/tailwindcss:*)",
"Bash(npm run build:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -10,46 +10,48 @@ jobs:
test-python:
name: Test Python
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'chore(ci): Bump version to')"
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
if: "!contains(github.event.head_commit.message, 'chore(ci): Bump version to')"
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
run: npm run build

1
.gitignore vendored
View File

@@ -164,3 +164,4 @@ sql/
leggen.db
*.db
config.toml
.claude/

17
.mcp.json Normal file
View File

@@ -0,0 +1,17 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
},
"browsermcp": {
"command": "npx",
"args": [
"@browsermcp/mcp@latest"
]
}
}
}

View File

@@ -1,4 +1,90 @@
## 2025.9.14 (2025/09/18)
### Bug Fixes
- **config:** Remove aliases for configuration keys that were disabling telegram notifications in some cases. ([61442a59](https://github.com/elisiariocouto/leggen/commit/61442a598fa7f38c568e3df7e1d924ed85df7491))
### Miscellaneous Tasks
- **ci:** Prevent double GitHub Actions runs on new releases. ([30d7c2ed](https://github.com/elisiariocouto/leggen/commit/30d7c2ed4e9aff144837a1f0ed67a8ded0b5d72a))
## 2025.9.14 (2025/09/18)
### Bug Fixes
- **config:** Remove aliases for configuration keys that were disabling telegram notifications in some cases. ([61442a59](https://github.com/elisiariocouto/leggen/commit/61442a598fa7f38c568e3df7e1d924ed85df7491))
### Miscellaneous Tasks
- **ci:** Prevent double GitHub Actions runs on new releases. ([30d7c2ed](https://github.com/elisiariocouto/leggen/commit/30d7c2ed4e9aff144837a1f0ed67a8ded0b5d72a))
## 2025.9.13 (2025/09/17)
### Bug Fixes
- **frontend:** Resolve linting issue in skeleton component ([fb310a59](https://github.com/elisiariocouto/leggen/commit/fb310a5953cf51d1cac181529311e76a0f4ea9ee))
- **frontend:** Add index signature to PieDataPoint interface. ([81d7d163](https://github.com/elisiariocouto/leggen/commit/81d7d16301dafc62a95f63036819565ffb90ddb5))
- **frontend:** Resolve dual scroll and excessive whitespace issues on transactions page. ([8ab76081](https://github.com/elisiariocouto/leggen/commit/8ab760815c9ae072b8c2cb2460e31144b193e0b3))
- **frontend:** Remove broken running balance feature in transactions table. ([155a48d7](https://github.com/elisiariocouto/leggen/commit/155a48d7dc86b3f453ba6f8c37edf63c0b76c755))
### Features
- **frontend:** Complete shadcn migration of skeleton and styling components ([c83386b1](https://github.com/elisiariocouto/leggen/commit/c83386b1d5b165910abe8b391ca483e5b48cd35f))
- **frontend:** Add comprehensive PWA capabilities with dynamic theme support ([86891441](https://github.com/elisiariocouto/leggen/commit/86891441d65e13757f343cabc39ccdb3ca6adc75))
- **frontend:** Add PWA install prompts, update notifications, and app shortcuts ([3049a8cd](https://github.com/elisiariocouto/leggen/commit/3049a8cd2fa80c14f970884fb14df2ab88c418dd))
- **frontend:** Update brand identity with new logo and color scheme. ([2825dba2](https://github.com/elisiariocouto/leggen/commit/2825dba2e944b3fe31aaa33127b770e7474ce021))
- **frontend:** Update analytics cards to match home page design consistency. ([d9a39c30](https://github.com/elisiariocouto/leggen/commit/d9a39c30ab1248a9fdacff068d401c3daff3f6a5))
### Miscellaneous Tasks
- Enable browsermcp and shadcn MCP servers. ([5a626b53](https://github.com/elisiariocouto/leggen/commit/5a626b53947f7e2d1544faf3ee06f8a0f1fb5d7a))
### Refactor
- **frontend:** Replace LoadingSpinner with shadcn skeleton components. ([84e609a7](https://github.com/elisiariocouto/leggen/commit/84e609a774ddc0caf9f84eaf1e8cdce021c82785))
## 2025.9.13 (2025/09/17)
### Bug Fixes
- **frontend:** Resolve linting issue in skeleton component ([fb310a59](https://github.com/elisiariocouto/leggen/commit/fb310a5953cf51d1cac181529311e76a0f4ea9ee))
- **frontend:** Add index signature to PieDataPoint interface. ([81d7d163](https://github.com/elisiariocouto/leggen/commit/81d7d16301dafc62a95f63036819565ffb90ddb5))
- **frontend:** Resolve dual scroll and excessive whitespace issues on transactions page. ([8ab76081](https://github.com/elisiariocouto/leggen/commit/8ab760815c9ae072b8c2cb2460e31144b193e0b3))
- **frontend:** Remove broken running balance feature in transactions table. ([155a48d7](https://github.com/elisiariocouto/leggen/commit/155a48d7dc86b3f453ba6f8c37edf63c0b76c755))
### Features
- **frontend:** Complete shadcn migration of skeleton and styling components ([c83386b1](https://github.com/elisiariocouto/leggen/commit/c83386b1d5b165910abe8b391ca483e5b48cd35f))
- **frontend:** Add comprehensive PWA capabilities with dynamic theme support ([86891441](https://github.com/elisiariocouto/leggen/commit/86891441d65e13757f343cabc39ccdb3ca6adc75))
- **frontend:** Add PWA install prompts, update notifications, and app shortcuts ([3049a8cd](https://github.com/elisiariocouto/leggen/commit/3049a8cd2fa80c14f970884fb14df2ab88c418dd))
- **frontend:** Update brand identity with new logo and color scheme. ([2825dba2](https://github.com/elisiariocouto/leggen/commit/2825dba2e944b3fe31aaa33127b770e7474ce021))
- **frontend:** Update analytics cards to match home page design consistency. ([d9a39c30](https://github.com/elisiariocouto/leggen/commit/d9a39c30ab1248a9fdacff068d401c3daff3f6a5))
### Miscellaneous Tasks
- Enable browsermcp and shadcn MCP servers. ([5a626b53](https://github.com/elisiariocouto/leggen/commit/5a626b53947f7e2d1544faf3ee06f8a0f1fb5d7a))
### Refactor
- **frontend:** Replace LoadingSpinner with shadcn skeleton components. ([84e609a7](https://github.com/elisiariocouto/leggen/commit/84e609a774ddc0caf9f84eaf1e8cdce021c82785))
## 2025.9.12 (2025/09/15)

View File

@@ -146,8 +146,8 @@ enabled = true
# Optional: Transaction filters for notifications
[filters]
case-insensitive = ["salary", "utility"]
case-sensitive = ["SpecificStore"]
case_insensitive = ["salary", "utility"]
case_sensitive = ["SpecificStore"]
```
## 📖 Usage

View File

@@ -9,7 +9,7 @@ sqlite = true
# Optional: Background sync scheduling
[scheduler.sync]
enabled = true
hour = 3 # 3 AM
hour = 3 # 3 AM
minute = 0
# cron = "0 3 * * *" # Alternative: use cron expression
@@ -20,11 +20,11 @@ enabled = true
# Optional: Telegram notifications
[notifications.telegram]
api-key = "your-bot-token"
chat-id = 12345
token = "your-bot-token"
chat_id = 12345
enabled = true
# Optional: Transaction filters for notifications
[filters]
case-insensitive = ["salary", "utility"]
case-sensitive = ["SpecificStore"]
case_insensitive = ["salary", "utility"]
case_sensitive = ["SpecificStore"]

View File

@@ -1,7 +0,0 @@
{
"permissions": {
"allow": ["Bash(find:*)"],
"deny": [],
"ask": []
}
}

1
frontend/.gitignore vendored
View File

@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
dev-dist
*.local
# Editor directories and files

View File

@@ -6,7 +6,7 @@ import tseslint from "typescript-eslint";
import { globalIgnores } from "eslint/config";
export default tseslint.config([
globalIgnores(["dist"]),
globalIgnores(["dist", "dev-dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [

View File

@@ -2,9 +2,35 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Leggen</title>
<!-- PWA Meta Tags -->
<meta name="description" content="Personal finance management application" />
<meta name="application-name" content="Leggen" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Leggen" />
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<meta name="msapplication-TileColor" content="#0b74de" />
<meta name="msapplication-tap-highlight" content="no" />
<!-- Dynamic theme-color - will be updated by JavaScript -->
<meta name="theme-color" content="#0b74de" id="theme-color-meta" />
<meta name="msapplication-navbutton-color" content="#0b74de" id="ms-theme-color-meta" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" id="apple-status-bar-meta" />
<!-- Icons -->
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<link rel="mask-icon" href="/favicon.svg" color="#0b74de" />
<link rel="shortcut icon" href="/favicon.ico" />
<!-- Manifest -->
<link rel="manifest" href="/manifest.webmanifest" />
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -42,13 +42,16 @@
"@tanstack/router-vite-plugin": "^1.131.36",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vite-pwa/assets-generator": "^1.0.1",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"sharp": "^0.34.3",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",
"vite": "^7.1.2"
"vite": "^7.1.2",
"vite-plugin-pwa": "^1.0.3"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/pwa-192x192.png"/>
<TileColor>#3B82F6</TileColor>
</tile>
</msapplication>
</browserconfig>

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 B

View File

@@ -1,4 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#3B82F6"/>
<path d="M8 24V8h6c2.2 0 4 1.8 4 4v4c0 2.2-1.8 4-4 4H12v4H8zm4-8h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-2v4z" fill="white"/>
<svg xmlns="http://www.w3.org/2000/svg"
width="32" height="32"
viewBox="0 0 32 32"
role="img" aria-labelledby="title desc">
<title id="title">leggen — stylized italic L</title>
<desc id="desc">Square gradient background with italic white L.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0b74de"/>
<stop offset="100%" stop-color="#06b6d4"/>
</linearGradient>
</defs>
<!-- Square background -->
<rect width="32" height="32" fill="url(#bg)" rx="4"/>
<!-- Italic L -->
<text x="11" y="22"
font-family="Inter, Roboto, Arial, sans-serif"
font-weight="700"
font-size="20"
font-style="italic"
fill="#fff">
L
</text>
</svg>

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 B

View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: /sitemap.xml

View File

@@ -0,0 +1,4 @@
{
"preset": "minimal-2023",
"images": ["public/favicon.svg"]
}

View File

@@ -0,0 +1,156 @@
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

@@ -1,6 +1,5 @@
import { Link, useLocation } from "@tanstack/react-router";
import {
CreditCard,
Home,
List,
BarChart3,
@@ -8,6 +7,7 @@ import {
TrendingUp,
X,
} from "lucide-react";
import { Logo } from "./ui/logo";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api";
import { formatCurrency } from "../lib/utils";
@@ -53,7 +53,7 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
onClick={() => setSidebarOpen(false)}
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
>
<CreditCard className="h-8 w-8 text-primary" />
<Logo size={32} />
<h1 className="text-xl font-bold text-card-foreground">Leggen</h1>
</Link>
<button

View File

@@ -31,7 +31,7 @@ import { DataTablePagination } from "./ui/data-table-pagination";
import { Card, CardContent } from "./ui/card";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
import type { Account, Transaction, ApiResponse, Balance } from "../types/api";
import type { Account, Transaction, ApiResponse } from "../types/api";
export default function TransactionsTable() {
// Filter state consolidated into a single object
@@ -47,7 +47,6 @@ export default function TransactionsTable() {
const [showRawModal, setShowRawModal] = useState(false);
const [selectedTransaction, setSelectedTransaction] =
useState<Transaction | null>(null);
const [showRunningBalance, setShowRunningBalance] = useState(true);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
@@ -102,11 +101,6 @@ export default function TransactionsTable() {
queryFn: apiClient.getAccounts,
});
const { data: balances } = useQuery<Balance[]>({
queryKey: ["balances"],
queryFn: apiClient.getBalances,
enabled: showRunningBalance,
});
const {
data: transactionsResponse,
@@ -185,58 +179,6 @@ export default function TransactionsTable() {
filterState.minAmount ||
filterState.maxAmount;
// Calculate running balances
const calculateRunningBalances = (transactions: Transaction[]) => {
if (!balances || !showRunningBalance) return {};
const runningBalances: { [key: string]: number } = {};
const accountBalanceMap = new Map<string, number>();
// Create a map of account current balances - use interimAvailable as the most current
balances.forEach((balance) => {
if (balance.balance_type === "interimAvailable") {
accountBalanceMap.set(balance.account_id, balance.balance_amount);
}
});
// Group transactions by account
const transactionsByAccount = new Map<string, Transaction[]>();
transactions.forEach((txn) => {
if (!transactionsByAccount.has(txn.account_id)) {
transactionsByAccount.set(txn.account_id, []);
}
transactionsByAccount.get(txn.account_id)!.push(txn);
});
// Calculate running balance for each account
transactionsByAccount.forEach((accountTransactions, accountId) => {
const currentBalance = accountBalanceMap.get(accountId) || 0;
// Sort transactions by date (oldest first) for forward calculation
const sortedTransactions = [...accountTransactions].sort(
(a, b) =>
new Date(a.transaction_date).getTime() -
new Date(b.transaction_date).getTime(),
);
// Calculate the starting balance by working backwards from current balance
let startingBalance = currentBalance;
for (let i = sortedTransactions.length - 1; i >= 0; i--) {
startingBalance -= sortedTransactions[i].transaction_value;
}
// Now calculate running balances going forward chronologically
let runningBalance = startingBalance;
sortedTransactions.forEach((txn) => {
runningBalance += txn.transaction_value;
runningBalances[`${txn.account_id}-${txn.transaction_id}`] = runningBalance;
});
});
return runningBalances;
};
const runningBalances = calculateRunningBalances(transactions);
// Define columns
const columns: ColumnDef<Transaction>[] = [
@@ -313,29 +255,6 @@ export default function TransactionsTable() {
},
sortingFn: "basic",
},
...(showRunningBalance
? [
{
id: "running_balance",
header: "Running Balance",
cell: ({ row }: { row: { original: Transaction } }) => {
const transaction = row.original;
const balanceKey = `${transaction.account_id}-${transaction.transaction_id}`;
const balance = runningBalances[balanceKey];
if (balance === undefined) return null;
return (
<div className="text-right">
<p className="text-sm font-medium text-foreground">
{formatCurrency(balance, transaction.transaction_currency)}
</p>
</div>
);
},
},
]
: []),
{
accessorKey: "transaction_date",
header: "Date",
@@ -443,7 +362,7 @@ export default function TransactionsTable() {
}
return (
<div className="space-y-6">
<div className="space-y-6 max-w-full">
{/* New FilterBar */}
<FilterBar
filterState={filterState}
@@ -451,10 +370,6 @@ export default function TransactionsTable() {
onClearFilters={handleClearFilters}
accounts={accounts}
isSearchLoading={isSearchLoading}
showRunningBalance={showRunningBalance}
onToggleRunningBalance={() =>
setShowRunningBalance(!showRunningBalance)
}
/>
{/* Results Summary */}
@@ -490,93 +405,91 @@ export default function TransactionsTable() {
</Card>
{/* Responsive Table/Cards */}
<Card className="overflow-hidden">
<Card>
{/* Desktop Table View (hidden on mobile) */}
<div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/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-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted"
onClick={header.column.getToggleSortingHandler()}
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/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-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted"
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-primary"
: "text-muted-foreground"
}`}
/>
<ChevronDown
className={`h-3 w-3 -mt-1 ${
header.column.getIsSorted() === "desc"
? "text-primary"
: "text-muted-foreground"
}`}
/>
</div>
)}
</div>
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-card divide-y divide-border">
{table.getRowModel().rows.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="px-6 py-12 text-center"
>
<div className="text-muted-foreground mb-4">
<TrendingUp className="h-12 w-12 mx-auto" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
No transactions found
</h3>
<p className="text-muted-foreground">
{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-muted/50">
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className="px-6 py-4 whitespace-nowrap"
>
<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-primary"
: "text-muted-foreground"
}`}
/>
<ChevronDown
className={`h-3 w-3 -mt-1 ${
header.column.getIsSorted() === "desc"
? "text-primary"
: "text-muted-foreground"
}`}
/>
</div>
)}
</div>
</th>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))}
</thead>
<tbody className="bg-card divide-y divide-border">
{table.getRowModel().rows.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="px-6 py-12 text-center"
>
<div className="text-muted-foreground mb-4">
<TrendingUp className="h-12 w-12 mx-auto" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
No transactions found
</h3>
<p className="text-muted-foreground">
{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-muted/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>
))
)}
</tbody>
</table>
</div>
{/* Mobile Card View (visible only on mobile) */}
@@ -676,17 +589,6 @@ export default function TransactionsTable() {
transaction.transaction_currency,
)}
</p>
{showRunningBalance && (
<p className="text-xs text-muted-foreground mb-1">
Balance:{" "}
{formatCurrency(
runningBalances[
`${transaction.account_id}-${transaction.transaction_id}`
] || 0,
transaction.transaction_currency,
)}
</p>
)}
<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"

View File

@@ -12,6 +12,7 @@ interface StatCardProps {
isPositive: boolean;
};
className?: string;
iconColor?: "green" | "blue" | "red" | "purple" | "orange" | "default";
}
export default function StatCard({
@@ -21,43 +22,58 @@ export default function StatCard({
icon: Icon,
trend,
className,
iconColor = "default",
}: StatCardProps) {
return (
<Card className={cn(className)}>
<CardContent className="p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Icon className="h-8 w-8 text-primary" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-muted-foreground truncate">
{title}
</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-foreground">
{value}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
{title}
</p>
<div className="flex items-baseline">
<p className="text-2xl font-bold text-foreground">
{value}
</p>
{trend && (
<div
className={cn(
"ml-2 flex items-baseline text-sm font-semibold",
trend.isPositive
? "text-green-600 dark:text-green-400"
: "text-red-600 dark:text-red-400",
)}
>
{trend.isPositive ? "+" : ""}
{trend.value}%
</div>
{trend && (
<div
className={cn(
"ml-2 flex items-baseline text-sm font-semibold",
trend.isPositive
? "text-green-600 dark:text-green-400"
: "text-red-600 dark:text-red-400",
)}
>
{trend.isPositive ? "+" : ""}
{trend.value}%
</div>
)}
</dd>
{subtitle && (
<dd className="text-sm text-muted-foreground mt-1">
{subtitle}
</dd>
)}
</dl>
</div>
{subtitle && (
<p className="text-sm text-muted-foreground mt-1">
{subtitle}
</p>
)}
</div>
<div className={cn(
"p-3 rounded-full",
iconColor === "green" && "bg-green-100 dark:bg-green-900/20",
iconColor === "blue" && "bg-blue-100 dark:bg-blue-900/20",
iconColor === "red" && "bg-red-100 dark:bg-red-900/20",
iconColor === "purple" && "bg-purple-100 dark:bg-purple-900/20",
iconColor === "orange" && "bg-orange-100 dark:bg-orange-900/20",
iconColor === "default" && "bg-muted"
)}>
<Icon className={cn(
"h-6 w-6",
iconColor === "green" && "text-green-600",
iconColor === "blue" && "text-blue-600",
iconColor === "red" && "text-red-600",
iconColor === "purple" && "text-purple-600",
iconColor === "orange" && "text-orange-600",
iconColor === "default" && "text-muted-foreground"
)} />
</div>
</div>
</CardContent>

View File

@@ -23,8 +23,6 @@ export interface FilterBarProps {
onClearFilters: () => void;
accounts?: Account[];
isSearchLoading?: boolean;
showRunningBalance: boolean;
onToggleRunningBalance: () => void;
className?: string;
}
@@ -34,8 +32,6 @@ export function FilterBar({
onClearFilters,
accounts,
isSearchLoading = false,
showRunningBalance,
onToggleRunningBalance,
className,
}: FilterBarProps) {
const hasActiveFilters =
@@ -59,13 +55,6 @@ export function FilterBar({
<h3 className="text-lg font-semibold text-card-foreground">
Transactions
</h3>
<Button
onClick={onToggleRunningBalance}
variant={showRunningBalance ? "default" : "outline"}
size="sm"
>
Balance
</Button>
</div>
{/* Primary Filters Row */}

View File

@@ -0,0 +1,44 @@
interface LogoProps {
className?: string;
size?: number;
}
export function Logo({ className = "", size = 32 }: LogoProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 32 32"
className={className}
role="img"
aria-labelledby="logo-title logo-desc"
>
<title id="logo-title">leggen stylized italic L</title>
<desc id="logo-desc">Square gradient background with italic white L.</desc>
<defs>
<linearGradient id="logo-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#0b74de" />
<stop offset="100%" stopColor="#06b6d4" />
</linearGradient>
</defs>
{/* Square background */}
<rect width="32" height="32" fill="url(#logo-bg)" rx="4" />
{/* Italic L */}
<text
x="11"
y="22"
fontFamily="Inter, Roboto, Arial, sans-serif"
fontWeight="700"
fontSize="20"
fontStyle="italic"
fill="#fff"
>
L
</text>
</svg>
);
}

View File

@@ -10,6 +10,12 @@ interface ThemeContextType {
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// Theme colors for different modes
const THEME_COLORS = {
light: "#0b74de", // Primary brand color
dark: "#0f0f23", // Dark background color that matches typical dark themes
} as const;
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem("theme") as Theme;
@@ -40,6 +46,28 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
// Add resolved theme class
root.classList.add(resolvedTheme);
// Update theme-color meta tags for PWA status bar
const themeColor = THEME_COLORS[resolvedTheme];
// Update theme-color meta tag
const themeColorMeta = document.getElementById("theme-color-meta") as HTMLMetaElement;
if (themeColorMeta) {
themeColorMeta.content = themeColor;
}
// Update Microsoft tile color
const msThemeColorMeta = document.getElementById("ms-theme-color-meta") as HTMLMetaElement;
if (msThemeColorMeta) {
msThemeColorMeta.content = themeColor;
}
// Update Apple status bar style for better iOS integration
const appleStatusBarMeta = document.getElementById("apple-status-bar-meta") as HTMLMetaElement;
if (appleStatusBarMeta) {
// Use 'black-translucent' for dark theme, 'default' for light theme
appleStatusBarMeta.content = resolvedTheme === "dark" ? "black-translucent" : "default";
}
};
updateActualTheme();

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
interface PWAUpdate {
updateAvailable: boolean;
updateSW: () => Promise<void>;
}
export function usePWA(): PWAUpdate {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateSW, setUpdateSW] = useState<() => Promise<void>>(() => async () => {});
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,
};
}

View File

@@ -10,10 +10,10 @@
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary: 219 91% 46%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--secondary: 189 94% 43%;
--secondary-foreground: 210 40% 98%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
@@ -37,9 +37,9 @@
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--primary: 219 91% 46%;
--primary-foreground: 210 40% 98%;
--secondary: 189 94% 43%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;

View File

@@ -2,12 +2,28 @@ import { createRootRoute, Outlet } from "@tanstack/react-router";
import { useState } from "react";
import Sidebar from "../components/Sidebar";
import Header from "../components/Header";
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
import { usePWA } from "../hooks/usePWA";
function RootLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { updateAvailable, updateSW } = usePWA();
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 (
<div className="flex h-screen bg-background">
<div className="flex min-h-screen bg-background">
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
{/* Mobile overlay */}
@@ -18,12 +34,19 @@ function RootLayout() {
/>
)}
<div className="flex flex-col flex-1 overflow-hidden">
<div className="flex flex-col flex-1">
<Header setSidebarOpen={setSidebarOpen} />
<main className="flex-1 overflow-y-auto p-6">
<main className="flex-1 p-6">
<Outlet />
</main>
</div>
{/* PWA Prompts */}
<PWAInstallPrompt onInstall={handlePWAInstall} />
<PWAUpdatePrompt
updateAvailable={updateAvailable}
onUpdate={handlePWAUpdate}
/>
</div>
);
}

View File

@@ -80,20 +80,21 @@ function AnalyticsDashboard() {
value={stats?.total_transactions || 0}
subtitle={`Last ${stats?.period_days || 0} days`}
icon={Activity}
iconColor="blue"
/>
<StatCard
title="Total Income"
value={`${(stats?.total_income || 0).toLocaleString()}`}
subtitle="Inflows this period"
icon={TrendingUp}
className="border-green-200"
iconColor="green"
/>
<StatCard
title="Total Expenses"
value={`${(stats?.total_expenses || 0).toLocaleString()}`}
subtitle="Outflows this period"
icon={TrendingDown}
className="border-red-200"
iconColor="red"
/>
</div>
@@ -104,23 +105,21 @@ function AnalyticsDashboard() {
value={`${(stats?.net_change || 0).toLocaleString()}`}
subtitle="Income minus expenses"
icon={CreditCard}
className={
(stats?.net_change || 0) >= 0
? "border-green-200"
: "border-red-200"
}
iconColor={(stats?.net_change || 0) >= 0 ? "green" : "red"}
/>
<StatCard
title="Average Transaction"
value={`${Math.abs(stats?.average_transaction || 0).toLocaleString()}`}
subtitle="Per transaction"
icon={Activity}
iconColor="purple"
/>
<StatCard
title="Active Accounts"
value={stats?.accounts_included || 0}
subtitle="With recent activity"
icon={Users}
iconColor="orange"
/>
</div>

View File

@@ -1 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

View File

@@ -1,10 +1,88 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
import { VitePWA } from "vite-plugin-pwa";
// https://vite.dev/config/
export default defineConfig({
plugins: [TanStackRouterVite(), react()],
plugins: [
TanStackRouterVite(),
react(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["favicon.ico", "apple-touch-icon-180x180.png", "maskable-icon-512x512.png", "robots.txt"],
manifest: {
name: "Leggen",
short_name: "Leggen",
description: "Personal finance management application",
theme_color: "#0b74de",
background_color: "#ffffff",
display: "standalone",
orientation: "portrait",
scope: "/",
start_url: "/",
categories: ["finance", "productivity"],
shortcuts: [
{
name: "Transactions",
short_name: "Transactions",
description: "View and manage transactions",
url: "/transactions",
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }]
},
{
name: "Analytics",
short_name: "Analytics",
description: "View financial analytics",
url: "/analytics",
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }]
}
],
icons: [
{
src: "pwa-64x64.png",
sizes: "64x64",
type: "image/png"
},
{
src: "pwa-192x192.png",
sizes: "192x192",
type: "image/png"
},
{
src: "pwa-512x512.png",
sizes: "512x512",
type: "image/png"
},
{
src: "maskable-icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable"
}
],
},
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
runtimeCaching: [
{
urlPattern: /^https:\/\/.*\/api\//,
handler: "NetworkFirst",
options: {
cacheName: "api-cache",
networkTimeoutSeconds: 10,
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
},
devOptions: {
enabled: true,
},
}),
],
resolve: {
alias: {
"@": "/src",

View File

@@ -37,15 +37,15 @@ async def get_notification_settings() -> APIResponse:
if discord_config.get("webhook")
else None,
telegram=TelegramConfig(
token="***" if telegram_config.get("api-key") else "",
chat_id=telegram_config.get("chat-id", 0),
token="***" if telegram_config.get("token") else "",
chat_id=telegram_config.get("chat_id", 0),
enabled=telegram_config.get("enabled", True),
)
if telegram_config.get("api-key")
if telegram_config.get("token")
else None,
filters=NotificationFilters(
case_insensitive=filters_config.get("case-insensitive", []),
case_sensitive=filters_config.get("case-sensitive"),
case_insensitive=filters_config.get("case_insensitive", []),
case_sensitive=filters_config.get("case_sensitive"),
),
)
@@ -77,17 +77,17 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
if settings.telegram:
notifications_config["telegram"] = {
"api-key": settings.telegram.token,
"chat-id": settings.telegram.chat_id,
"token": settings.telegram.token,
"chat_id": settings.telegram.chat_id,
"enabled": settings.telegram.enabled,
}
# Update filters config
filters_config: Dict[str, Any] = {}
if settings.filters.case_insensitive:
filters_config["case-insensitive"] = settings.filters.case_insensitive
filters_config["case_insensitive"] = settings.filters.case_insensitive
if settings.filters.case_sensitive:
filters_config["case-sensitive"] = settings.filters.case_sensitive
filters_config["case_sensitive"] = settings.filters.case_sensitive
# Save to config
if notifications_config:
@@ -153,12 +153,12 @@ async def get_notification_services() -> APIResponse:
"telegram": {
"name": "Telegram",
"enabled": bool(
notifications_config.get("telegram", {}).get("api-key")
and notifications_config.get("telegram", {}).get("chat-id")
notifications_config.get("telegram", {}).get("token")
and notifications_config.get("telegram", {}).get("chat_id")
),
"configured": bool(
notifications_config.get("telegram", {}).get("api-key")
and notifications_config.get("telegram", {}).get("chat-id")
notifications_config.get("telegram", {}).get("token")
and notifications_config.get("telegram", {}).get("chat_id")
),
"active": notifications_config.get("telegram", {}).get("enabled", True),
},

View File

@@ -22,8 +22,8 @@ class DiscordNotificationConfig(BaseModel):
class TelegramNotificationConfig(BaseModel):
token: str = Field(..., alias="api-key", description="Telegram bot token")
chat_id: int = Field(..., alias="chat-id", description="Telegram chat ID")
token: str = Field(..., description="Telegram bot token")
chat_id: int = Field(..., description="Telegram chat ID")
enabled: bool = Field(default=True, description="Enable Telegram notifications")
@@ -33,12 +33,8 @@ class NotificationConfig(BaseModel):
class FilterConfig(BaseModel):
case_insensitive: Optional[List[str]] = Field(
default_factory=list, alias="case-insensitive"
)
case_sensitive: Optional[List[str]] = Field(
default_factory=list, alias="case-sensitive"
)
case_insensitive: Optional[List[str]] = Field(default_factory=list)
case_sensitive: Optional[List[str]] = Field(default_factory=list)
class SyncScheduleConfig(BaseModel):
@@ -60,6 +56,3 @@ class Config(BaseModel):
notifications: Optional[NotificationConfig] = None
filters: Optional[FilterConfig] = None
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
class Config:
validate_by_name = True

View File

@@ -29,8 +29,8 @@ def escape_markdown(text: str) -> str:
def send_expire_notification(ctx: click.Context, notification: dict):
token = ctx.obj["notifications"]["telegram"]["api-key"]
chat_id = ctx.obj["notifications"]["telegram"]["chat-id"]
token = ctx.obj["notifications"]["telegram"]["token"]
chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
info("Sending expiration notification to Telegram")
message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
@@ -54,8 +54,8 @@ def send_expire_notification(ctx: click.Context, notification: dict):
def send_transaction_message(ctx: click.Context, transactions: list):
token = ctx.obj["notifications"]["telegram"]["api-key"]
chat_id = ctx.obj["notifications"]["telegram"]["chat-id"]
token = ctx.obj["notifications"]["telegram"]["token"]
chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
info(f"Got {len(transactions)} new transactions, sending message to Telegram")
message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n"

View File

@@ -63,8 +63,8 @@ class NotificationService:
) -> List[Dict[str, Any]]:
"""Filter transactions based on notification criteria"""
matching = []
filters_case_insensitive = self.filters_config.get("case-insensitive", [])
filters_case_sensitive = self.filters_config.get("case-sensitive", [])
filters_case_insensitive = self.filters_config.get("case_insensitive", [])
filters_case_sensitive = self.filters_config.get("case_sensitive", [])
for transaction in transactions:
description = transaction.get("description", "")
@@ -159,8 +159,8 @@ class NotificationService:
ctx.obj = {
"notifications": {
"telegram": {
"api-key": telegram_config.get("token"),
"chat-id": telegram_config.get("chat_id"),
"token": telegram_config.get("token"),
"chat_id": telegram_config.get("chat_id"),
}
}
}
@@ -219,8 +219,8 @@ class NotificationService:
ctx.obj = {
"notifications": {
"telegram": {
"api-key": telegram_config.get("token"),
"chat-id": telegram_config.get("chat_id"),
"token": telegram_config.get("token"),
"chat_id": telegram_config.get("chat_id"),
}
}
}
@@ -277,8 +277,8 @@ class NotificationService:
ctx.obj = {
"notifications": {
"telegram": {
"api-key": telegram_config.get("token"),
"chat-id": telegram_config.get("chat_id"),
"token": telegram_config.get("token"),
"chat_id": telegram_config.get("chat_id"),
}
}
}

View File

@@ -29,7 +29,7 @@ def send_notification(ctx: click.Context, transactions: list):
warning("No filters are enabled, skipping notifications")
return
filters_case_insensitive = ctx.obj.get("filters", {}).get("case-insensitive", {})
filters_case_insensitive = ctx.obj.get("filters", {}).get("case_insensitive", {})
# Add transaction to the list of transactions to be sent as a notification
notification_transactions = []

View File

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

View File

@@ -216,8 +216,8 @@ class TestConfig:
"""Test filters configuration access."""
custom_config = {
"filters": {
"case-insensitive": ["salary", "utility"],
"case-sensitive": ["SpecificStore"],
"case_insensitive": ["salary", "utility"],
"case_sensitive": ["SpecificStore"],
}
}
@@ -225,6 +225,6 @@ class TestConfig:
config._config = custom_config
filters = config.filters_config
assert "salary" in filters["case-insensitive"]
assert "utility" in filters["case-insensitive"]
assert "SpecificStore" in filters["case-sensitive"]
assert "salary" in filters["case_insensitive"]
assert "utility" in filters["case_insensitive"]
assert "SpecificStore" in filters["case_sensitive"]

View File

@@ -1,192 +0,0 @@
"""Tests for running balance calculation logic in frontend.
This test validates the running balance calculation algorithm to ensure
it correctly computes account balances after each transaction.
"""
import pytest
class TestRunningBalanceCalculation:
"""Test running balance calculation logic."""
def test_running_balance_calculation_logic(self):
"""Test the logic behind running balance calculation.
This test validates the algorithm used in the frontend TransactionsTable
component to ensure running balances are calculated correctly.
"""
# Sample test data similar to what the frontend receives
transactions = [
{
"transaction_id": "txn-1",
"account_id": "account-001",
"transaction_value": -100.00,
"transaction_date": "2025-01-01T10:00:00Z",
"description": "Initial expense"
},
{
"transaction_id": "txn-2",
"account_id": "account-001",
"transaction_value": 500.00,
"transaction_date": "2025-01-02T10:00:00Z",
"description": "Salary"
},
{
"transaction_id": "txn-3",
"account_id": "account-001",
"transaction_value": -50.00,
"transaction_date": "2025-01-03T10:00:00Z",
"description": "Shopping"
}
]
balances = [
{
"account_id": "account-001",
"balance_amount": 350.00, # Final balance after all transactions
"balance_type": "interimAvailable"
}
]
# Implement the corrected algorithm from the frontend
running_balances = self._calculate_running_balances(transactions, balances)
# Expected running balances:
# - After txn-1 (-100): balance should be -100.00
# - After txn-2 (+500): balance should be 400.00 (previous + 500)
# - After txn-3 (-50): balance should be 350.00 (previous - 50)
assert running_balances["account-001-txn-1"] == -100.00, "First transaction running balance incorrect"
assert running_balances["account-001-txn-2"] == 400.00, "Second transaction running balance incorrect"
assert running_balances["account-001-txn-3"] == 350.00, "Third transaction running balance incorrect"
# Final balance should match current account balance
final_calculated_balance = running_balances["account-001-txn-3"]
assert final_calculated_balance == balances[0]["balance_amount"], "Final balance doesn't match current account balance"
def test_running_balance_multiple_accounts(self):
"""Test running balance calculation with multiple accounts."""
transactions = [
{
"transaction_id": "txn-1",
"account_id": "account-001",
"transaction_value": -50.00,
"transaction_date": "2025-01-01T10:00:00Z",
"description": "Account 1 expense"
},
{
"transaction_id": "txn-2",
"account_id": "account-002",
"transaction_value": -25.00,
"transaction_date": "2025-01-01T11:00:00Z",
"description": "Account 2 expense"
},
{
"transaction_id": "txn-3",
"account_id": "account-001",
"transaction_value": 100.00,
"transaction_date": "2025-01-02T10:00:00Z",
"description": "Account 1 income"
}
]
balances = [
{
"account_id": "account-001",
"balance_amount": 50.00,
"balance_type": "interimAvailable"
},
{
"account_id": "account-002",
"balance_amount": 75.00,
"balance_type": "interimAvailable"
}
]
running_balances = self._calculate_running_balances(transactions, balances)
# Account 1: starts at 0, -50 = -50, +100 = 50
assert running_balances["account-001-txn-1"] == -50.00
assert running_balances["account-001-txn-3"] == 50.00
# Account 2: starts at 100, -25 = 75
assert running_balances["account-002-txn-2"] == 75.00
def test_running_balance_empty_transactions(self):
"""Test running balance calculation with no transactions."""
transactions = []
balances = [
{
"account_id": "account-001",
"balance_amount": 100.00,
"balance_type": "interimAvailable"
}
]
running_balances = self._calculate_running_balances(transactions, balances)
assert running_balances == {}
def test_running_balance_no_balances(self):
"""Test running balance calculation with no balance data."""
transactions = [
{
"transaction_id": "txn-1",
"account_id": "account-001",
"transaction_value": -50.00,
"transaction_date": "2025-01-01T10:00:00Z",
"description": "Expense"
}
]
balances = []
running_balances = self._calculate_running_balances(transactions, balances)
# When no balance data available, current balance is 0
# Working backwards: starting_balance = 0 - (-50) = 50
# Going forward: running_balance = 50 + (-50) = 0
assert running_balances["account-001-txn-1"] == 0.00
def _calculate_running_balances(self, transactions, balances):
"""
Implementation of the corrected running balance calculation algorithm.
This mirrors the logic implemented in the frontend TransactionsTable component.
"""
running_balances = {}
account_balance_map = {}
# Create a map of account current balances - use interimAvailable as the most current
for balance in balances:
if balance["balance_type"] == "interimAvailable":
account_balance_map[balance["account_id"]] = balance["balance_amount"]
# Group transactions by account
transactions_by_account = {}
for txn in transactions:
account_id = txn["account_id"]
if account_id not in transactions_by_account:
transactions_by_account[account_id] = []
transactions_by_account[account_id].append(txn)
# Calculate running balance for each account
for account_id, account_transactions in transactions_by_account.items():
current_balance = account_balance_map.get(account_id, 0)
# Sort transactions by date (oldest first) for forward calculation
sorted_transactions = sorted(
account_transactions,
key=lambda x: x["transaction_date"]
)
# Calculate the starting balance by working backwards from current balance
starting_balance = current_balance
for txn in reversed(sorted_transactions):
starting_balance -= txn["transaction_value"]
# Now calculate running balances going forward chronologically
running_balance = starting_balance
for txn in sorted_transactions:
running_balance += txn["transaction_value"]
running_balances[f"{txn['account_id']}-{txn['transaction_id']}"] = running_balance
return running_balances

2
uv.lock generated
View File

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