Compare commits

..

8 Commits

Author SHA1 Message Date
Elisiário Couto
8228974c0c chore(ci): Bump version to 2025.9.17 2025-09-18 23:45:10 +01:00
Elisiário Couto
848eccb35b chore: Format files. 2025-09-18 23:43:08 +01:00
Elisiário Couto
25747d7d37 fix(api): Prevent duplicate notifications for existing transactions during sync.
The notification system was incorrectly sending notifications for existing
transactions that were being updated during sync operations. This change
modifies the transaction persistence logic to only return genuinely new
transactions, preventing duplicate notifications while maintaining data
integrity through INSERT OR REPLACE.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 23:42:11 +01:00
Elisiário Couto
b7d6cf8128 chore(ci): Bump version to 2025.9.16 2025-09-18 23:29:53 +01:00
copilot-swe-agent[bot]
6589c2dd66 fix(frontend): Add iOS safe area support for PWA sticky header
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-18 23:28:49 +01:00
Elisiário Couto
571072f6ac chore(ci): Bump version to 2025.9.15 2025-09-18 23:03:01 +01:00
Elisiário Couto
be4f7f8cec refactor(frontend): Simplify filter bar UI and remove advanced filters popover.
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 23:01:51 +01:00
Elisiário Couto
056c33b9c5 feat(frontend): Add settings page with account management functionality.
Added comprehensive settings page with account settings component, integrated with existing layout and routing
structure. Updated project documentation with frontend architecture details.

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

Co-Authored-By: Claude <noreply@anthropic.com>")
2025-09-18 22:19:36 +01:00
30 changed files with 3387 additions and 567 deletions

View File

@@ -143,19 +143,19 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install git-cliff
run: |
wget -qO- https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-2.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz
sudo mv git-cliff-*/git-cliff /usr/local/bin/
- name: Generate release notes
id: release_notes
run: |
echo "notes<<EOF" >> $GITHUB_OUTPUT
git-cliff --current >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
with:

View File

@@ -85,6 +85,29 @@ The command outputs instructions for setting the required environment variable t
- **Data fetching**: @tanstack/react-query with proper error handling
- **Components**: Functional components with hooks, proper TypeScript typing
## Frontend Structure
### Layout Architecture
- **Root Layout**: `frontend/src/routes/__root.tsx` - Contains main app structure with Sidebar and Header
- **Header/Navbar**: `frontend/src/components/Header.tsx` - Top navigation bar (sticky on mobile only)
- **Sidebar**: `frontend/src/components/Sidebar.tsx` - Left navigation sidebar
- **Routes**: `frontend/src/routes/` - TanStack Router file-based routing
### Key Components Location
- **UI Components**: `frontend/src/components/ui/` - Reusable UI primitives
- **Feature Components**: `frontend/src/components/` - Main app components
- **Pages**: `frontend/src/routes/` - Route components (index.tsx, transactions.tsx, etc.)
- **Hooks**: `frontend/src/hooks/` - Custom React hooks
- **API**: `frontend/src/lib/api.ts` - API client configuration
- **Context**: `frontend/src/contexts/` - React contexts (ThemeContext, etc.)
### Routing Structure
- `/` - Overview/Dashboard (TransactionsTable component)
- `/transactions` - Transactions page
- `/analytics` - Analytics page
- `/notifications` - Notifications page
- `/settings` - Settings page
### General
- **Formatting**: ruff for Python, ESLint for TypeScript
- **Commits**: Use conventional commits with optional scopes, run pre-commit hooks before pushing

View File

@@ -1,4 +1,72 @@
## 2025.9.17 (2025/09/18)
### Bug Fixes
- **api:** Prevent duplicate notifications for existing transactions during sync. ([25747d7d](https://github.com/elisiariocouto/leggen/commit/25747d7d372e291090764a6814f9d8d0b76aea3b))
### Miscellaneous Tasks
- Format files. ([848eccb3](https://github.com/elisiariocouto/leggen/commit/848eccb35b910c8121d15611547dca8da0b12756))
## 2025.9.17 (2025/09/18)
### Bug Fixes
- **api:** Prevent duplicate notifications for existing transactions during sync. ([25747d7d](https://github.com/elisiariocouto/leggen/commit/25747d7d372e291090764a6814f9d8d0b76aea3b))
### Miscellaneous Tasks
- Format files. ([848eccb3](https://github.com/elisiariocouto/leggen/commit/848eccb35b910c8121d15611547dca8da0b12756))
## 2025.9.16 (2025/09/18)
### Bug Fixes
- **frontend:** Add iOS safe area support for PWA sticky header ([6589c2dd](https://github.com/elisiariocouto/leggen/commit/6589c2dd666f8605cf6d1bf9ad7277734d4cd302))
## 2025.9.16 (2025/09/18)
### Bug Fixes
- **frontend:** Add iOS safe area support for PWA sticky header ([6589c2dd](https://github.com/elisiariocouto/leggen/commit/6589c2dd666f8605cf6d1bf9ad7277734d4cd302))
## 2025.9.15 (2025/09/18)
### Features
- **frontend:** Add settings page with account management functionality. ([056c33b9](https://github.com/elisiariocouto/leggen/commit/056c33b9c5cfbc2842cc2dd4ca8c4e3959a2be80))
### Refactor
- **frontend:** Simplify filter bar UI and remove advanced filters popover. ([be4f7f8c](https://github.com/elisiariocouto/leggen/commit/be4f7f8cecfe2564abdf0ce1be08497e5a6d7b68))
## 2025.9.15 (2025/09/18)
### Features
- **frontend:** Add settings page with account management functionality. ([056c33b9](https://github.com/elisiariocouto/leggen/commit/056c33b9c5cfbc2842cc2dd4ca8c4e3959a2be80))
### Refactor
- **frontend:** Simplify filter bar UI and remove advanced filters popover. ([be4f7f8c](https://github.com/elisiariocouto/leggen/commit/be4f7f8cecfe2564abdf0ce1be08497e5a6d7b68))
## 2025.9.14 (2025/09/18)
### Bug Fixes

File diff suppressed because it is too large Load Diff

View File

@@ -48,6 +48,7 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"shadcn": "^3.3.1",
"sharp": "^0.34.3",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",

View File

@@ -6,4 +6,4 @@
<TileColor>#3B82F6</TileColor>
</tile>
</msapplication>
</browserconfig>
</browserconfig>

View File

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

View File

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

View File

@@ -0,0 +1,347 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
CreditCard,
TrendingUp,
TrendingDown,
Building2,
RefreshCw,
AlertCircle,
Edit2,
Check,
X,
Plus,
} from "lucide-react";
import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import AccountsSkeleton from "./AccountsSkeleton";
import type { Account, Balance } from "../types/api";
// Helper function to get status indicator color and styles
const getStatusIndicator = (status: string) => {
const statusLower = status.toLowerCase();
switch (statusLower) {
case "ready":
return {
color: "bg-green-500",
tooltip: "Ready",
};
case "pending":
return {
color: "bg-amber-500",
tooltip: "Pending",
};
case "error":
case "failed":
return {
color: "bg-destructive",
tooltip: "Error",
};
case "inactive":
return {
color: "bg-muted-foreground",
tooltip: "Inactive",
};
default:
return {
color: "bg-primary",
tooltip: status,
};
}
};
export default function AccountSettings() {
const {
data: accounts,
isLoading: accountsLoading,
error: accountsError,
refetch: refetchAccounts,
} = useQuery<Account[]>({
queryKey: ["accounts"],
queryFn: apiClient.getAccounts,
});
const { data: balances } = useQuery<Balance[]>({
queryKey: ["balances"],
queryFn: () => apiClient.getBalances(),
});
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const queryClient = useQueryClient();
const updateAccountMutation = useMutation({
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
apiClient.updateAccount(id, { display_name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
setEditingAccountId(null);
setEditingName("");
},
onError: (error) => {
console.error("Failed to update account:", error);
},
});
const handleEditStart = (account: Account) => {
setEditingAccountId(account.id);
// Use display_name if available, otherwise fall back to name
setEditingName(account.display_name || account.name || "");
};
const handleEditSave = () => {
if (editingAccountId && editingName.trim()) {
updateAccountMutation.mutate({
id: editingAccountId,
display_name: editingName.trim(),
});
}
};
const handleEditCancel = () => {
setEditingAccountId(null);
setEditingName("");
};
if (accountsLoading) {
return <AccountsSkeleton />;
}
if (accountsError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load accounts</AlertTitle>
<AlertDescription className="space-y-3">
<p>
Unable to connect to the Leggen API. Please check your configuration
and ensure the API server is running.
</p>
<Button onClick={() => refetchAccounts()} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
{/* Account Management Section */}
<Card>
<CardHeader>
<CardTitle>Account Management</CardTitle>
<CardDescription>
Manage your connected bank accounts and customize their display names
</CardDescription>
</CardHeader>
{!accounts || accounts.length === 0 ? (
<CardContent className="p-6 text-center">
<CreditCard className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No accounts found
</h3>
<p className="text-muted-foreground mb-4">
Connect your first bank account to get started with Leggen.
</p>
<Button disabled className="flex items-center space-x-2">
<Plus className="h-4 w-4" />
<span>Add Bank Account</span>
</Button>
<p className="text-xs text-muted-foreground mt-2">
Coming soon: Add new bank connections
</p>
</CardContent>
) : (
<CardContent className="p-0">
<div className="divide-y divide-border">
{accounts.map((account) => {
// Get balance from account's balances array or fallback to balances query
const accountBalance = account.balances?.[0];
const fallbackBalance = balances?.find(
(b) => b.account_id === account.id,
);
const balance =
accountBalance?.amount ||
fallbackBalance?.balance_amount ||
0;
const currency =
accountBalance?.currency ||
fallbackBalance?.currency ||
account.currency ||
"EUR";
const isPositive = balance >= 0;
return (
<div
key={account.id}
className="p-4 sm:p-6 hover:bg-accent transition-colors"
>
{/* Mobile layout - stack vertically */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full">
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
{editingAccountId === account.id ? (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<input
type="text"
value={editingName}
onChange={(e) =>
setEditingName(e.target.value)
}
className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Custom account name"
name="search"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave();
if (e.key === "Escape") handleEditCancel();
}}
autoFocus
/>
<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"
title="Save changes"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleEditCancel}
className="p-1 text-gray-600 hover:text-gray-700"
title="Cancel editing"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
</p>
</div>
) : (
<div>
<div className="flex items-center space-x-2 min-w-0">
<h4 className="text-base sm:text-lg font-medium text-foreground truncate">
{account.display_name ||
account.name ||
"Unnamed Account"}
</h4>
<button
onClick={() => handleEditStart(account)}
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
title="Edit account name"
>
<Edit2 className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
</p>
{account.iban && (
<p className="text-xs text-muted-foreground mt-1 font-mono break-all sm:break-normal">
IBAN: {account.iban}
</p>
)}
</div>
)}
</div>
</div>
{/* Balance and date section */}
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
{/* Mobile: date/status on left, balance on right */}
{/* Desktop: balance on top, date/status on bottom */}
{/* Date and status indicator - left on mobile, bottom on desktop */}
<div className="flex items-center space-x-2 order-1 sm:order-2">
<div
className={`w-3 h-3 rounded-full ${getStatusIndicator(account.status).color} relative group cursor-help`}
role="img"
aria-label={`Account status: ${getStatusIndicator(account.status).tooltip}`}
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10">
{getStatusIndicator(account.status).tooltip}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
Updated{" "}
{formatDate(
account.last_accessed || account.created,
)}
</p>
</div>
{/* Balance - right on mobile, top on desktop */}
<div className="flex items-center space-x-2 order-2 sm:order-1">
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<p
className={`text-base sm:text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
</p>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
{/* Add Bank Section (Future Feature) */}
<Card>
<CardHeader>
<CardTitle>Add New Bank Account</CardTitle>
<CardDescription>
Connect additional bank accounts to track all your finances in one place
</CardDescription>
</CardHeader>
<CardContent className="p-6">
<div className="text-center space-y-4">
<div className="p-4 bg-muted rounded-lg">
<Plus className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
Bank connection functionality is coming soon. Stay tuned for updates!
</p>
</div>
<Button disabled variant="outline">
<Plus className="h-4 w-4 mr-2" />
Connect Bank Account
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -9,6 +9,7 @@ const navigation = [
{ name: "Transactions", to: "/transactions" },
{ name: "Analytics", to: "/analytics" },
{ name: "Notifications", to: "/notifications" },
{ name: "Settings", to: "/settings" },
];
interface HeaderProps {
@@ -32,7 +33,7 @@ export default function Header({ setSidebarOpen }: HeaderProps) {
});
return (
<header className="bg-card shadow-sm border-b border-border">
<header className="lg:static sticky top-0 z-50 bg-card shadow-sm border-b border-border pt-safe-top">
<div className="flex items-center justify-between h-16 px-6">
<div className="flex items-center">
<button

View File

@@ -33,11 +33,11 @@ export function PWAInstallPrompt({ onInstall }: PWAPromptProps) {
try {
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
onInstall?.();
}
setDeferredPrompt(null);
setShowPrompt(false);
} catch (error) {
@@ -153,4 +153,4 @@ export function PWAUpdatePrompt({ updateAvailable, onUpdate }: PWAUpdatePromptPr
</div>
</div>
);
}
}

View File

@@ -1,24 +1,29 @@
import { Link, useLocation } from "@tanstack/react-router";
import {
Home,
List,
BarChart3,
Bell,
TrendingUp,
X,
ChevronDown,
ChevronUp,
Settings,
Building2,
} from "lucide-react";
import { Logo } from "./ui/logo";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api";
import { formatCurrency } from "../lib/utils";
import { cn } from "../lib/utils";
import { useState } from "react";
import type { Account } from "../types/api";
const navigation = [
{ name: "Overview", icon: Home, to: "/" },
{ name: "Transactions", icon: List, to: "/transactions" },
{ name: "Overview", icon: List, to: "/" },
{ name: "Analytics", icon: BarChart3, to: "/analytics" },
{ name: "Notifications", icon: Bell, to: "/notifications" },
{ name: "Settings", icon: Settings, to: "/settings" },
];
interface SidebarProps {
@@ -28,6 +33,7 @@ interface SidebarProps {
export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
const location = useLocation();
const [accountsExpanded, setAccountsExpanded] = useState(false);
const { data: accounts } = useQuery<Account[]>({
queryKey: ["accounts"],
@@ -85,21 +91,68 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
</div>
</nav>
{/* Account Summary in Sidebar */}
<div className="px-6 py-4 border-t border-border mt-auto">
<div className="bg-muted rounded-lg p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
Total Balance
</span>
<TrendingUp className="h-4 w-4 text-green-500" />
{/* Collapsible Account Summary in Sidebar */}
<div className="px-6 pt-4 pb-6 border-t border-border mt-auto">
<div className="bg-muted rounded-lg">
{/* Collapsible Header */}
<button
onClick={() => setAccountsExpanded(!accountsExpanded)}
className="w-full p-4 flex items-center justify-between hover:bg-muted/80 transition-colors rounded-lg"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-muted-foreground">
Total Balance
</span>
<TrendingUp className="h-4 w-4 text-green-500" />
</div>
{accountsExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</button>
<div className="px-4 pb-2">
<p className="text-2xl font-bold text-foreground">
{formatCurrency(totalBalance)}
</p>
<p className="text-sm text-muted-foreground">
{accounts?.length || 0} accounts
</p>
</div>
<p className="text-2xl font-bold text-foreground mt-1">
{formatCurrency(totalBalance)}
</p>
<p className="text-sm text-muted-foreground mt-1">
{accounts?.length || 0} accounts
</p>
{/* Expanded Account Details */}
{accountsExpanded && accounts && accounts.length > 0 && (
<div className="border-t border-border/50 max-h-64 overflow-y-auto">
{accounts.map((account) => {
const primaryBalance = account.balances?.[0]?.amount || 0;
const currency = account.balances?.[0]?.currency || account.currency || "EUR";
return (
<div
key={account.id}
className="p-3 border-b border-border/30 last:border-b-0 hover:bg-muted/50 transition-colors"
>
<div className="flex items-start space-x-2">
<div className="flex-shrink-0 p-1 bg-background rounded">
<Building2 className="h-3 w-3 text-muted-foreground" />
</div>
<div className="space-y-1 min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate">
{account.display_name || account.name || "Unnamed Account"}
</p>
<p className="text-sm font-semibold text-foreground">
{formatCurrency(primaryBalance, currency)}
</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>

View File

@@ -28,7 +28,7 @@ import FiltersSkeleton from "./FiltersSkeleton";
import RawTransactionModal from "./RawTransactionModal";
import { FilterBar, type FilterState } from "./filters";
import { DataTablePagination } from "./ui/data-table-pagination";
import { Card, CardContent } from "./ui/card";
import { Card } from "./ui/card";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
import type { Account, Transaction, ApiResponse } from "../types/api";
@@ -40,8 +40,6 @@ export default function TransactionsTable() {
selectedAccount: "",
startDate: "",
endDate: "",
minAmount: "",
maxAmount: "",
});
const [showRawModal, setShowRawModal] = useState(false);
@@ -73,8 +71,6 @@ export default function TransactionsTable() {
selectedAccount: "",
startDate: "",
endDate: "",
minAmount: "",
maxAmount: "",
});
setColumnFilters([]);
setCurrentPage(1);
@@ -116,8 +112,6 @@ export default function TransactionsTable() {
currentPage,
perPage,
debouncedSearchTerm,
filterState.minAmount,
filterState.maxAmount,
],
queryFn: () =>
apiClient.getTransactions({
@@ -128,12 +122,6 @@ export default function TransactionsTable() {
perPage: perPage,
search: debouncedSearchTerm || undefined,
summaryOnly: false,
minAmount: filterState.minAmount
? parseFloat(filterState.minAmount)
: undefined,
maxAmount: filterState.maxAmount
? parseFloat(filterState.maxAmount)
: undefined,
}),
});
@@ -157,8 +145,6 @@ export default function TransactionsTable() {
filterState.selectedAccount,
filterState.startDate,
filterState.endDate,
filterState.minAmount,
filterState.maxAmount,
]);
const handleViewRaw = (transaction: Transaction) => {
@@ -175,9 +161,7 @@ export default function TransactionsTable() {
filterState.searchTerm ||
filterState.selectedAccount ||
filterState.startDate ||
filterState.endDate ||
filterState.minAmount ||
filterState.maxAmount;
filterState.endDate;
// Define columns
@@ -372,38 +356,6 @@ export default function TransactionsTable() {
isSearchLoading={isSearchLoading}
/>
{/* Results Summary */}
<Card>
<CardContent className="px-6 py-3 bg-muted/30 border-b border-border">
<p className="text-sm text-muted-foreground">
Showing {transactions.length} transaction
{transactions.length !== 1 ? "s" : ""} (
{pagination ? (
<>
{(pagination.page - 1) * pagination.per_page + 1}-
{Math.min(
pagination.page * pagination.per_page,
pagination.total,
)}{" "}
of {pagination.total}
</>
) : (
"loading..."
)}
)
{filterState.selectedAccount && accounts && (
<span className="ml-1">
for{" "}
{
accounts.find((acc) => acc.id === filterState.selectedAccount)
?.name
}
</span>
)}
</p>
</CardContent>
</Card>
{/* Responsive Table/Cards */}
<Card>
{/* Desktop Table View (hidden on mobile) */}

View File

@@ -51,7 +51,7 @@ export function AccountCombobox({
variant="outline"
role="combobox"
aria-expanded={open}
className="justify-between"
className="w-full justify-between"
>
<div className="flex items-center">
<Building2 className="mr-2 h-4 w-4" />

View File

@@ -8,12 +8,14 @@ import type { Account } from "../../types/api";
export interface ActiveFilterChipsProps {
filterState: FilterState;
onFilterChange: (key: keyof FilterState, value: string) => void;
onClearFilters: () => void;
accounts?: Account[];
}
export function ActiveFilterChips({
filterState,
onFilterChange,
onClearFilters,
accounts = [],
}: ActiveFilterChipsProps) {
const chips: Array<{
@@ -68,30 +70,6 @@ export function ActiveFilterChips({
});
}
// Amount range chips
if (filterState.minAmount || filterState.maxAmount) {
let amountLabel = "Amount: ";
const minAmount = filterState.minAmount
? parseFloat(filterState.minAmount)
: null;
const maxAmount = filterState.maxAmount
? parseFloat(filterState.maxAmount)
: null;
if (minAmount && maxAmount) {
amountLabel += `${minAmount} - €${maxAmount}`;
} else if (minAmount) {
amountLabel += `≥ €${minAmount}`;
} else if (maxAmount) {
amountLabel += `≤ €${maxAmount}`;
}
chips.push({
key: "minAmount", // We'll clear both min and max when removing this chip
label: amountLabel,
value: `${filterState.minAmount}-${filterState.maxAmount}`,
});
}
const handleRemoveChip = (key: keyof FilterState) => {
switch (key) {
@@ -100,11 +78,6 @@ export function ActiveFilterChips({
onFilterChange("startDate", "");
onFilterChange("endDate", "");
break;
case "minAmount":
// Clear both min and max amount
onFilterChange("minAmount", "");
onFilterChange("maxAmount", "");
break;
default:
onFilterChange(key, "");
}
@@ -135,6 +108,15 @@ export function ActiveFilterChips({
</Button>
</Badge>
))}
<Button
onClick={onClearFilters}
variant="outline"
size="sm"
className="text-muted-foreground ml-2"
>
<X className="h-4 w-4 mr-1" />
Clear All
</Button>
</div>
);
}

View File

@@ -1,127 +0,0 @@
import { useState } from "react";
import { MoreHorizontal, Euro } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export interface AdvancedFiltersPopoverProps {
minAmount: string;
maxAmount: string;
onMinAmountChange: (value: string) => void;
onMaxAmountChange: (value: string) => void;
}
export function AdvancedFiltersPopover({
minAmount,
maxAmount,
onMinAmountChange,
onMaxAmountChange,
}: AdvancedFiltersPopoverProps) {
const [open, setOpen] = useState(false);
const hasAdvancedFilters = minAmount || maxAmount;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant={hasAdvancedFilters ? "default" : "outline"}
size="default"
className="relative"
>
<MoreHorizontal className="h-4 w-4 mr-2" />
More
{hasAdvancedFilters && (
<div className="absolute -top-1 -right-1 h-2 w-2 bg-blue-600 rounded-full" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">Advanced Filters</h4>
<p className="text-sm text-muted-foreground">
Additional filters for more precise results
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Amount Range
</label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">
Minimum
</label>
<div className="relative">
<Euro className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="number"
placeholder="0.00"
value={minAmount}
onChange={(e) => onMinAmountChange(e.target.value)}
className="pl-8"
step="0.01"
min="0"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">
Maximum
</label>
<div className="relative">
<Euro className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="number"
placeholder="1000.00"
value={maxAmount}
onChange={(e) => onMaxAmountChange(e.target.value)}
className="pl-8"
step="0.01"
min="0"
/>
</div>
</div>
</div>
<p className="text-xs text-muted-foreground">
Leave empty for no limit
</p>
</div>
{/* Future: Add transaction status filter */}
<div className="pt-2 border-t">
<div className="text-xs text-muted-foreground">
More filters coming soon: transaction status, categories, and
more.
</div>
</div>
{/* Clear advanced filters */}
{hasAdvancedFilters && (
<div className="pt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
onMinAmountChange("");
onMaxAmountChange("");
}}
className="w-full"
>
Clear Advanced Filters
</Button>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -6,6 +6,7 @@ import type { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import {
Popover,
PopoverContent,
@@ -26,33 +27,35 @@ interface DatePreset {
const datePresets: DatePreset[] = [
{
label: "Last 7 days",
label: "Today",
getValue: () => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 7);
const today = new Date();
return {
startDate: startDate.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0],
startDate: today.toISOString().split("T")[0],
endDate: today.toISOString().split("T")[0],
};
},
},
{
label: "This week",
label: "Yesterday",
getValue: () => {
const now = new Date();
const dayOfWeek = now.getDay();
const startOfWeek = new Date(now);
startOfWeek.setDate(
now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1),
); // Monday as start
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return {
startDate: startOfWeek.toISOString().split("T")[0],
endDate: endOfWeek.toISOString().split("T")[0],
startDate: yesterday.toISOString().split("T")[0],
endDate: yesterday.toISOString().split("T")[0],
};
},
},
{
label: "Last 7 days",
getValue: () => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 6);
return {
startDate: startDate.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0],
};
},
},
@@ -61,7 +64,7 @@ const datePresets: DatePreset[] = [
getValue: () => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 30);
startDate.setDate(endDate.getDate() - 29);
return {
startDate: startDate.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0],
@@ -81,19 +84,6 @@ const datePresets: DatePreset[] = [
};
},
},
{
label: "This year",
getValue: () => {
const now = new Date();
const startOfYear = new Date(now.getFullYear(), 0, 1);
const endOfYear = new Date(now.getFullYear(), 11, 31);
return {
startDate: startOfYear.toISOString().split("T")[0],
endDate: endOfYear.toISOString().split("T")[0],
};
},
},
];
export function DateRangePicker({
@@ -178,34 +168,30 @@ export function DateRangePicker({
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="flex">
{/* Presets */}
<div className="border-r p-3 space-y-1">
<div className="text-sm font-medium text-gray-700 mb-2">
Quick select
</div>
<Card className="w-auto py-4">
<CardContent className="px-4">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={handleDateRangeSelect}
className="bg-transparent p-0"
/>
</CardContent>
<CardFooter className="grid grid-cols-2 gap-1 border-t px-4 !pt-4">
{datePresets.map((preset) => (
<Button
key={preset.label}
variant="ghost"
variant="outline"
size="sm"
className="w-full justify-start text-sm"
className="text-xs px-2 h-7"
onClick={() => handlePresetClick(preset)}
>
{preset.label}
</Button>
))}
</div>
{/* Calendar */}
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={handleDateRangeSelect}
numberOfMonths={2}
/>
</div>
</CardFooter>
</Card>
</PopoverContent>
</Popover>
</div>

View File

@@ -1,11 +1,9 @@
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { DateRangePicker } from "./DateRangePicker";
import { AccountCombobox } from "./AccountCombobox";
import { ActiveFilterChips } from "./ActiveFilterChips";
import { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
import type { Account } from "../../types/api";
export interface FilterState {
@@ -13,8 +11,6 @@ export interface FilterState {
selectedAccount: string;
startDate: string;
endDate: string;
minAmount: string;
maxAmount: string;
}
export interface FilterBarProps {
@@ -38,9 +34,7 @@ export function FilterBar({
filterState.searchTerm ||
filterState.selectedAccount ||
filterState.startDate ||
filterState.endDate ||
filterState.minAmount ||
filterState.maxAmount;
filterState.endDate;
const handleDateRangeChange = (startDate: string, endDate: string) => {
onFilterChange("startDate", startDate);
@@ -58,61 +52,85 @@ export function FilterBar({
</div>
{/* Primary Filters Row */}
<div className="flex flex-wrap items-center gap-3 mb-4">
{/* Search Input */}
<div className="relative flex-1 min-w-[240px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search transactions..."
value={filterState.searchTerm}
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
className="pl-9 pr-8 bg-background"
/>
{isSearchLoading && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
<div className="space-y-4 mb-4">
{/* Desktop Layout */}
<div className="hidden lg:flex items-center justify-between gap-6">
{/* Left Side: Main Filters */}
<div className="flex items-center gap-3 flex-1">
{/* Search Input */}
<div className="relative w-[200px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search transactions..."
value={filterState.searchTerm}
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
className="pl-9 pr-8 bg-background"
/>
{isSearchLoading && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
</div>
)}
</div>
)}
{/* Account Selection */}
<AccountCombobox
accounts={accounts}
selectedAccount={filterState.selectedAccount}
onAccountChange={(accountId) =>
onFilterChange("selectedAccount", accountId)
}
className="w-[180px]"
/>
{/* Date Range Picker */}
<DateRangePicker
startDate={filterState.startDate}
endDate={filterState.endDate}
onDateRangeChange={handleDateRangeChange}
className="w-[220px]"
/>
</div>
</div>
{/* Account Selection */}
<AccountCombobox
accounts={accounts}
selectedAccount={filterState.selectedAccount}
onAccountChange={(accountId) =>
onFilterChange("selectedAccount", accountId)
}
className="w-[200px]"
/>
{/* Mobile Layout */}
<div className="lg:hidden space-y-3">
{/* First Row: Search Input (Full Width) */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={filterState.searchTerm}
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
className="pl-9 pr-8 bg-background w-full"
/>
{isSearchLoading && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
</div>
)}
</div>
{/* Date Range Picker */}
<DateRangePicker
startDate={filterState.startDate}
endDate={filterState.endDate}
onDateRangeChange={handleDateRangeChange}
className="w-[240px]"
/>
{/* Second Row: Account Selection (Full Width) */}
<AccountCombobox
accounts={accounts}
selectedAccount={filterState.selectedAccount}
onAccountChange={(accountId) =>
onFilterChange("selectedAccount", accountId)
}
className="w-full"
/>
{/* Advanced Filters Button */}
<AdvancedFiltersPopover
minAmount={filterState.minAmount}
maxAmount={filterState.maxAmount}
onMinAmountChange={(value) => onFilterChange("minAmount", value)}
onMaxAmountChange={(value) => onFilterChange("maxAmount", value)}
/>
{/* Third Row: Date Range */}
<DateRangePicker
startDate={filterState.startDate}
endDate={filterState.endDate}
onDateRangeChange={handleDateRangeChange}
className="w-full"
/>
{/* Clear Filters Button */}
{hasActiveFilters && (
<Button
onClick={onClearFilters}
variant="outline"
size="sm"
className="text-muted-foreground"
>
<X className="h-4 w-4 mr-1" />
Clear All
</Button>
)}
</div>
</div>
{/* Active Filter Chips */}
@@ -120,6 +138,7 @@ export function FilterBar({
<ActiveFilterChips
filterState={filterState}
onFilterChange={onFilterChange}
onClearFilters={onClearFilters}
accounts={accounts}
/>
)}

View File

@@ -2,5 +2,4 @@ export { FilterBar } from "./FilterBar";
export { DateRangePicker } from "./DateRangePicker";
export { AccountCombobox } from "./AccountCombobox";
export { ActiveFilterChips } from "./ActiveFilterChips";
export { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
export type { FilterState, FilterBarProps } from "./FilterBar";

View File

@@ -34,4 +34,4 @@ export function usePWA(): PWAUpdate {
updateAvailable,
updateSW,
};
}
}

View File

@@ -29,6 +29,12 @@
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
/* iOS Safe Area Support for PWA */
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
}
.dark {
--background: 222.2 84% 4.9%;

View File

@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as TransactionsRouteImport } from './routes/transactions'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as NotificationsRouteImport } from './routes/notifications'
import { Route as AnalyticsRouteImport } from './routes/analytics'
import { Route as IndexRouteImport } from './routes/index'
@@ -19,6 +20,11 @@ const TransactionsRoute = TransactionsRouteImport.update({
path: '/transactions',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const NotificationsRoute = NotificationsRouteImport.update({
id: '/notifications',
path: '/notifications',
@@ -39,12 +45,14 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/transactions': typeof TransactionsRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/transactions': typeof TransactionsRoute
}
export interface FileRoutesById {
@@ -52,20 +60,33 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/transactions': typeof TransactionsRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/analytics' | '/notifications' | '/transactions'
fullPaths:
| '/'
| '/analytics'
| '/notifications'
| '/settings'
| '/transactions'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/analytics' | '/notifications' | '/transactions'
id: '__root__' | '/' | '/analytics' | '/notifications' | '/transactions'
to: '/' | '/analytics' | '/notifications' | '/settings' | '/transactions'
id:
| '__root__'
| '/'
| '/analytics'
| '/notifications'
| '/settings'
| '/transactions'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AnalyticsRoute: typeof AnalyticsRoute
NotificationsRoute: typeof NotificationsRoute
SettingsRoute: typeof SettingsRoute
TransactionsRoute: typeof TransactionsRoute
}
@@ -78,6 +99,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TransactionsRouteImport
parentRoute: typeof rootRouteImport
}
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/notifications': {
id: '/notifications'
path: '/notifications'
@@ -106,6 +134,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AnalyticsRoute: AnalyticsRoute,
NotificationsRoute: NotificationsRoute,
SettingsRoute: SettingsRoute,
TransactionsRoute: TransactionsRoute,
}
export const routeTree = rootRouteImport

View File

@@ -34,9 +34,9 @@ function RootLayout() {
/>
)}
<div className="flex flex-col flex-1">
<div className="flex flex-col flex-1 min-w-0">
<Header setSidebarOpen={setSidebarOpen} />
<main className="flex-1 p-6">
<main className="flex-1 p-6 min-w-0">
<Outlet />
</main>
</div>

View File

@@ -1,6 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import AccountsOverview from "../components/AccountsOverview";
import TransactionsTable from "../components/TransactionsTable";
export const Route = createFileRoute("/")({
component: AccountsOverview,
component: TransactionsTable,
validateSearch: (search) => ({
accountId: search.accountId as string | undefined,
startDate: search.startDate as string | undefined,
endDate: search.endDate as string | undefined,
}),
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import AccountSettings from "../components/AccountSettings";
export const Route = createFileRoute("/settings")({
component: AccountSettings,
});

View File

@@ -9,6 +9,12 @@ export default {
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
spacing: {
'safe-top': 'var(--safe-area-inset-top)',
'safe-bottom': 'var(--safe-area-inset-bottom)',
'safe-left': 'var(--safe-area-inset-left)',
'safe-right': 'var(--safe-area-inset-right)',
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",

View File

@@ -693,7 +693,7 @@ class DatabaseService:
# Add the display_name column
cursor.execute("""
ALTER TABLE accounts
ALTER TABLE accounts
ADD COLUMN display_name TEXT
""")
@@ -857,6 +857,14 @@ class DatabaseService:
for transaction in transactions:
try:
# Check if transaction already exists before insertion
cursor.execute(
"""SELECT COUNT(*) FROM transactions
WHERE accountId = ? AND transactionId = ?""",
(transaction["accountId"], transaction["transactionId"]),
)
exists = cursor.fetchone()[0] > 0
cursor.execute(
insert_sql,
(
@@ -873,7 +881,11 @@ class DatabaseService:
json.dumps(transaction["rawTransaction"]),
),
)
new_transactions.append(transaction)
# Only add to new_transactions if it didn't exist before
if not exists:
new_transactions.append(transaction)
except sqlite3.IntegrityError as e:
logger.warning(
f"Failed to insert transaction {transaction.get('transactionId')}: {e}"

View File

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

View File

@@ -324,6 +324,8 @@ class TestDatabaseService:
with patch("sqlite3.connect") as mock_connect:
mock_conn = mock_connect.return_value
mock_cursor = mock_conn.cursor.return_value
# Mock fetchone to return (0,) indicating transaction doesn't exist yet
mock_cursor.fetchone.return_value = (0,)
result = await database_service._persist_transactions_sqlite(
"test-account-123", sample_transactions_db_format
@@ -338,6 +340,29 @@ class TestDatabaseService:
mock_conn.commit.assert_called_once()
mock_conn.close.assert_called_once()
async def test_persist_transactions_sqlite_duplicate_detection(
self, database_service, sample_transactions_db_format
):
"""Test that existing transactions are not returned as new."""
with patch("sqlite3.connect") as mock_connect:
mock_conn = mock_connect.return_value
mock_cursor = mock_conn.cursor.return_value
# Mock fetchone to return (1,) indicating transaction already exists
mock_cursor.fetchone.return_value = (1,)
result = await database_service._persist_transactions_sqlite(
"test-account-123", sample_transactions_db_format
)
# Should return empty list since all transactions already exist
assert len(result) == 0
# Verify database operations still happened (INSERT OR REPLACE executed)
mock_connect.assert_called()
mock_cursor.execute.assert_called()
mock_conn.commit.assert_called_once()
mock_conn.close.assert_called_once()
async def test_persist_transactions_sqlite_error(self, database_service):
"""Test handling error during transaction persistence."""
with patch("sqlite3.connect") as mock_connect:

2
uv.lock generated
View File

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