Compare commits

...

4 Commits

Author SHA1 Message Date
Elisiário Couto
5de9badfde fix(frontend): Blur balances in Account Management page. 2025-12-07 12:00:23 +00:00
Elisiário Couto
159cba508e fix: Resolve all lint warnings and type errors across frontend and backend.
Frontend:
- Memoize pagination object in TransactionsTable to prevent unnecessary re-renders and fix exhaustive-deps warning
- Add optional success and message fields to backup API response types for proper error handling

Backend:
- Add TypedDict for transaction type configuration to improve type safety in generate_sample_db
- Fix unpacking of amount_range with explicit float type hints
- Add explicit type hints for descriptions dictionary and specific_descriptions variable
- Fix sync endpoint return types: get_sync_status returns SyncStatus and sync_now returns SyncResult
- Fix transactions endpoint data type declaration to properly support Union types in PaginatedResponse

All checks now pass:
- Frontend: npm lint and npm build ✓
- Backend: mypy type checking ✓
- Backend: ruff lint on modified files ✓

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

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2025-12-07 12:00:23 +00:00
copilot-swe-agent[bot]
966440006a fix(frontend): Remove unused import in TransactionDistribution
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-12-07 12:00:23 +00:00
copilot-swe-agent[bot]
a592b827aa feat(frontend): Add balance visibility toggle with blur effect
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-12-07 12:00:23 +00:00
24 changed files with 223 additions and 73 deletions

View File

@@ -23,6 +23,7 @@ import {
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import AccountsSkeleton from "./AccountsSkeleton"; import AccountsSkeleton from "./AccountsSkeleton";
import { BlurredValue } from "./ui/blurred-value";
import type { Account, Balance } from "../types/api"; import type { Account, Balance } from "../types/api";
// Helper function to get status indicator color and styles // Helper function to get status indicator color and styles
@@ -158,7 +159,7 @@ export default function AccountsOverview() {
Total Balance Total Balance
</p> </p>
<p className="text-2xl font-bold text-foreground"> <p className="text-2xl font-bold text-foreground">
{formatCurrency(totalBalance)} <BlurredValue>{formatCurrency(totalBalance)}</BlurredValue>
</p> </p>
</div> </div>
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-full"> <div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-full">
@@ -369,7 +370,9 @@ export default function AccountsOverview() {
isPositive ? "text-green-600" : "text-red-600" isPositive ? "text-green-600" : "text-red-600"
}`} }`}
> >
<BlurredValue>
{formatCurrency(balance, currency)} {formatCurrency(balance, currency)}
</BlurredValue>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -16,6 +16,7 @@ import { apiClient } from "../lib/api";
import { formatCurrency } from "../lib/utils"; import { formatCurrency } from "../lib/utils";
import { useState } from "react"; import { useState } from "react";
import type { Account } from "../types/api"; import type { Account } from "../types/api";
import { BlurredValue } from "./ui/blurred-value";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -130,7 +131,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<div className="px-3 pb-2"> <div className="px-3 pb-2">
<p className="text-xl font-bold text-foreground"> <p className="text-xl font-bold text-foreground">
{formatCurrency(totalBalance)} <BlurredValue>{formatCurrency(totalBalance)}</BlurredValue>
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{accounts?.length || 0} accounts {accounts?.length || 0} accounts
@@ -163,7 +164,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
"Unnamed Account"} "Unnamed Account"}
</p> </p>
<p className="text-xs font-semibold text-foreground"> <p className="text-xs font-semibold text-foreground">
<BlurredValue>
{formatCurrency(primaryBalance, currency)} {formatCurrency(primaryBalance, currency)}
</BlurredValue>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -34,6 +34,7 @@ import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Label } from "./ui/label"; import { Label } from "./ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import { BlurredValue } from "./ui/blurred-value";
import AccountsSkeleton from "./AccountsSkeleton"; import AccountsSkeleton from "./AccountsSkeleton";
import NotificationFiltersDrawer from "./NotificationFiltersDrawer"; import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
import DiscordConfigDrawer from "./DiscordConfigDrawer"; import DiscordConfigDrawer from "./DiscordConfigDrawer";
@@ -491,13 +492,13 @@ export default function Settings() {
) : ( ) : (
<TrendingDown className="h-4 w-4 text-red-500" /> <TrendingDown className="h-4 w-4 text-red-500" />
)} )}
<p <BlurredValue
className={`text-base sm:text-lg font-semibold ${ className={`text-base sm:text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600" isPositive ? "text-green-600" : "text-red-600"
}`} }`}
> >
{formatCurrency(balance, currency)} {formatCurrency(balance, currency)}
</p> </BlurredValue>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import { Activity, Wifi, WifiOff } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api"; import { apiClient } from "../lib/api";
import { ThemeToggle } from "./ui/theme-toggle"; import { ThemeToggle } from "./ui/theme-toggle";
import { BalanceToggle } from "./ui/balance-toggle";
import { Separator } from "./ui/separator"; import { Separator } from "./ui/separator";
import { SidebarTrigger } from "./ui/sidebar"; import { SidebarTrigger } from "./ui/sidebar";
@@ -77,6 +78,7 @@ export function SiteHeader() {
</> </>
)} )}
</div> </div>
<BalanceToggle />
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
useReactTable, useReactTable,
@@ -31,7 +31,8 @@ import { DataTablePagination } from "./ui/data-table-pagination";
import { Card } from "./ui/card"; import { Card } from "./ui/card";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import type { Account, Transaction, ApiResponse } from "../types/api"; import { BlurredValue } from "./ui/blurred-value";
import type { Account, Transaction, PaginatedResponse } from "../types/api";
export default function TransactionsTable() { export default function TransactionsTable() {
// Filter state consolidated into a single object // Filter state consolidated into a single object
@@ -102,7 +103,7 @@ export default function TransactionsTable() {
isLoading: transactionsLoading, isLoading: transactionsLoading,
error: transactionsError, error: transactionsError,
refetch: refetchTransactions, refetch: refetchTransactions,
} = useQuery<ApiResponse<Transaction[]>>({ } = useQuery<PaginatedResponse<Transaction>>({
queryKey: [ queryKey: [
"transactions", "transactions",
filterState.selectedAccount, filterState.selectedAccount,
@@ -125,7 +126,20 @@ export default function TransactionsTable() {
}); });
const transactions = transactionsResponse?.data || []; const transactions = transactionsResponse?.data || [];
const pagination = transactionsResponse?.pagination; const pagination = useMemo(
() =>
transactionsResponse
? {
page: transactionsResponse.page,
total_pages: transactionsResponse.total_pages,
per_page: transactionsResponse.per_page,
total: transactionsResponse.total,
has_next: transactionsResponse.has_next,
has_prev: transactionsResponse.has_prev,
}
: undefined,
[transactionsResponse],
);
// Check if search is currently debouncing // Check if search is currently debouncing
const isSearchLoading = filterState.searchTerm !== debouncedSearchTerm; const isSearchLoading = filterState.searchTerm !== debouncedSearchTerm;
@@ -221,11 +235,13 @@ export default function TransactionsTable() {
isPositive ? "text-green-600" : "text-red-600" isPositive ? "text-green-600" : "text-red-600"
}`} }`}
> >
<BlurredValue>
{isPositive ? "+" : ""} {isPositive ? "+" : ""}
{formatCurrency( {formatCurrency(
transaction.transaction_value, transaction.transaction_value,
transaction.transaction_currency, transaction.transaction_currency,
)} )}
</BlurredValue>
</p> </p>
</div> </div>
); );
@@ -525,11 +541,13 @@ export default function TransactionsTable() {
isPositive ? "text-green-600" : "text-red-600" isPositive ? "text-green-600" : "text-red-600"
}`} }`}
> >
<BlurredValue>
{isPositive ? "+" : ""} {isPositive ? "+" : ""}
{formatCurrency( {formatCurrency(
transaction.transaction_value, transaction.transaction_value,
transaction.transaction_currency, transaction.transaction_currency,
)} )}
</BlurredValue>
</p> </p>
<Button <Button
onClick={() => handleViewRaw(transaction)} onClick={() => handleViewRaw(transaction)}

View File

@@ -8,6 +8,8 @@ import {
ResponsiveContainer, ResponsiveContainer,
Legend, Legend,
} from "recharts"; } from "recharts";
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
import { cn } from "../../lib/utils";
import type { Balance, Account } from "../../types/api"; import type { Balance, Account } from "../../types/api";
interface BalanceChartProps { interface BalanceChartProps {
@@ -42,6 +44,8 @@ export default function BalanceChart({
accounts, accounts,
className, className,
}: BalanceChartProps) { }: BalanceChartProps) {
const { isBalanceVisible } = useBalanceVisibility();
// Create a lookup map for account info // Create a lookup map for account info
const accountMap = accounts.reduce( const accountMap = accounts.reduce(
(map, account) => { (map, account) => {
@@ -149,7 +153,7 @@ export default function BalanceChart({
<h3 className="text-lg font-medium text-foreground mb-4"> <h3 className="text-lg font-medium text-foreground mb-4">
Balance Progress Over Time Balance Progress Over Time
</h3> </h3>
<div className="h-80"> <div className={cn("h-80", !isBalanceVisible && "blur-md select-none")}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={finalData}> <AreaChart data={finalData}>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" />

View File

@@ -8,6 +8,8 @@ import {
ResponsiveContainer, ResponsiveContainer,
} from "recharts"; } from "recharts";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
import { cn } from "../../lib/utils";
import apiClient from "../../lib/api"; import apiClient from "../../lib/api";
interface MonthlyTrendsProps { interface MonthlyTrendsProps {
@@ -29,6 +31,8 @@ export default function MonthlyTrends({
className, className,
days = 365, days = 365,
}: MonthlyTrendsProps) { }: MonthlyTrendsProps) {
const { isBalanceVisible } = useBalanceVisibility();
// Get pre-calculated monthly stats from the new endpoint // Get pre-calculated monthly stats from the new endpoint
const { data: monthlyData, isLoading } = useQuery({ const { data: monthlyData, isLoading } = useQuery({
queryKey: ["monthly-stats", days], queryKey: ["monthly-stats", days],
@@ -103,7 +107,7 @@ export default function MonthlyTrends({
<h3 className="text-lg font-medium text-foreground mb-4"> <h3 className="text-lg font-medium text-foreground mb-4">
{getTitle(days)} {getTitle(days)}
</h3> </h3>
<div className="h-80"> <div className={cn("h-80", !isBalanceVisible && "blur-md select-none")}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
data={displayData} data={displayData}

View File

@@ -1,5 +1,6 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Card, CardContent } from "../ui/card"; import { Card, CardContent } from "../ui/card";
import { BlurredValue } from "../ui/blurred-value";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
interface StatCardProps { interface StatCardProps {
@@ -13,6 +14,7 @@ interface StatCardProps {
}; };
className?: string; className?: string;
iconColor?: "green" | "blue" | "red" | "purple" | "orange" | "default"; iconColor?: "green" | "blue" | "red" | "purple" | "orange" | "default";
shouldBlur?: boolean;
} }
export default function StatCard({ export default function StatCard({
@@ -23,6 +25,7 @@ export default function StatCard({
trend, trend,
className, className,
iconColor = "default", iconColor = "default",
shouldBlur = false,
}: StatCardProps) { }: StatCardProps) {
return ( return (
<Card className={cn(className)}> <Card className={cn(className)}>
@@ -31,7 +34,9 @@ export default function StatCard({
<div> <div>
<p className="text-sm font-medium text-muted-foreground">{title}</p> <p className="text-sm font-medium text-muted-foreground">{title}</p>
<div className="flex items-baseline"> <div className="flex items-baseline">
<p className="text-2xl font-bold text-foreground">{value}</p> <p className="text-2xl font-bold text-foreground">
{shouldBlur ? <BlurredValue>{value}</BlurredValue> : value}
</p>
{trend && ( {trend && (
<div <div
className={cn( className={cn(

View File

@@ -6,6 +6,7 @@ import {
Tooltip, Tooltip,
Legend, Legend,
} from "recharts"; } from "recharts";
import { BlurredValue } from "../ui/blurred-value";
import type { Account } from "../../types/api"; import type { Account } from "../../types/api";
interface TransactionDistributionProps { interface TransactionDistributionProps {
@@ -85,7 +86,8 @@ export default function TransactionDistribution({
<div className="bg-card p-3 border rounded shadow-lg"> <div className="bg-card p-3 border rounded shadow-lg">
<p className="font-medium text-foreground">{data.name}</p> <p className="font-medium text-foreground">{data.name}</p>
<p className="text-primary"> <p className="text-primary">
Balance: {data.value.toLocaleString()} Balance:{" "}
<BlurredValue>{data.value.toLocaleString()}</BlurredValue>
</p> </p>
<p className="text-muted-foreground">{percentage}% of total</p> <p className="text-muted-foreground">{percentage}% of total</p>
</div> </div>
@@ -138,7 +140,7 @@ export default function TransactionDistribution({
<span className="text-foreground">{item.name}</span> <span className="text-foreground">{item.name}</span>
</div> </div>
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{item.value.toLocaleString()} <BlurredValue>{item.value.toLocaleString()}</BlurredValue>
</span> </span>
</div> </div>
))} ))}

View File

@@ -0,0 +1,26 @@
import { Eye, EyeOff } from "lucide-react";
import { Button } from "./button";
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
export function BalanceToggle() {
const { isBalanceVisible, toggleBalanceVisibility } = useBalanceVisibility();
return (
<Button
variant="outline"
size="icon"
onClick={toggleBalanceVisibility}
className="h-8 w-8"
title={isBalanceVisible ? "Hide balances" : "Show balances"}
>
{isBalanceVisible ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
<span className="sr-only">
{isBalanceVisible ? "Hide balances" : "Show balances"}
</span>
</Button>
);
}

View File

@@ -0,0 +1,23 @@
import React from "react";
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
import { cn } from "../../lib/utils";
interface BlurredValueProps {
children: React.ReactNode;
className?: string;
}
export function BlurredValue({ children, className }: BlurredValueProps) {
const { isBalanceVisible } = useBalanceVisibility();
return (
<span
className={cn(
isBalanceVisible ? "" : "blur-md select-none",
className,
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,48 @@
import React, { createContext, useContext, useEffect, useState } from "react";
interface BalanceVisibilityContextType {
isBalanceVisible: boolean;
toggleBalanceVisibility: () => void;
}
const BalanceVisibilityContext = createContext<
BalanceVisibilityContextType | undefined
>(undefined);
export function BalanceVisibilityProvider({
children,
}: {
children: React.ReactNode;
}) {
const [isBalanceVisible, setIsBalanceVisible] = useState<boolean>(() => {
const stored = localStorage.getItem("balanceVisible");
// Default to true (visible) if not set
return stored === null ? true : stored === "true";
});
useEffect(() => {
localStorage.setItem("balanceVisible", String(isBalanceVisible));
}, [isBalanceVisible]);
const toggleBalanceVisibility = () => {
setIsBalanceVisible((prev) => !prev);
};
return (
<BalanceVisibilityContext.Provider
value={{ isBalanceVisible, toggleBalanceVisibility }}
>
{children}
</BalanceVisibilityContext.Provider>
);
}
export function useBalanceVisibility() {
const context = useContext(BalanceVisibilityContext);
if (context === undefined) {
throw new Error(
"useBalanceVisibility must be used within a BalanceVisibilityProvider",
);
}
return context;
}

View File

@@ -288,11 +288,14 @@ export const apiClient = {
return response.data; return response.data;
}, },
testBackupConnection: async (test: BackupTest): Promise<{ connected?: boolean }> => { testBackupConnection: async (
const response = await api.post<{ connected?: boolean }>( test: BackupTest,
"/backup/test", ): Promise<{ connected?: boolean; success?: boolean; message?: string }> => {
test, const response = await api.post<{
); connected?: boolean;
success?: boolean;
message?: string;
}>("/backup/test", test);
return response.data; return response.data;
}, },
@@ -301,11 +304,20 @@ export const apiClient = {
return response.data; return response.data;
}, },
performBackupOperation: async (operation: BackupOperation): Promise<{ operation: string; completed: boolean }> => { performBackupOperation: async (
const response = await api.post<{ operation: string; completed: boolean }>( operation: BackupOperation,
"/backup/operation", ): Promise<{
operation, operation: string;
); completed: boolean;
success?: boolean;
message?: string;
}> => {
const response = await api.post<{
operation: string;
completed: boolean;
success?: boolean;
message?: string;
}>("/backup/operation", operation);
return response.data; return response.data;
}, },
}; };

View File

@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { createRouter, RouterProvider } from "@tanstack/react-router"; import { createRouter, RouterProvider } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "./contexts/ThemeContext"; import { ThemeProvider } from "./contexts/ThemeContext";
import { BalanceVisibilityProvider } from "./contexts/BalanceVisibilityContext";
import "./index.css"; import "./index.css";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
import { registerSW } from "virtual:pwa-register"; import { registerSW } from "virtual:pwa-register";
@@ -73,7 +74,9 @@ createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider> <ThemeProvider>
<BalanceVisibilityProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</BalanceVisibilityProvider>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>,

View File

@@ -88,6 +88,7 @@ function AnalyticsDashboard() {
subtitle="Inflows this period" subtitle="Inflows this period"
icon={TrendingUp} icon={TrendingUp}
iconColor="green" iconColor="green"
shouldBlur={true}
/> />
<StatCard <StatCard
title="Total Expenses" title="Total Expenses"
@@ -95,6 +96,7 @@ function AnalyticsDashboard() {
subtitle="Outflows this period" subtitle="Outflows this period"
icon={TrendingDown} icon={TrendingDown}
iconColor="red" iconColor="red"
shouldBlur={true}
/> />
</div> </div>
@@ -106,6 +108,7 @@ function AnalyticsDashboard() {
subtitle="Income minus expenses" subtitle="Income minus expenses"
icon={CreditCard} icon={CreditCard}
iconColor={(stats?.net_change || 0) >= 0 ? "green" : "red"} iconColor={(stats?.net_change || 0) >= 0 ? "green" : "red"}
shouldBlur={true}
/> />
<StatCard <StatCard
title="Average Transaction" title="Average Transaction"
@@ -113,6 +116,7 @@ function AnalyticsDashboard() {
subtitle="Per transaction" subtitle="Per transaction"
icon={Activity} icon={Activity}
iconColor="purple" iconColor="purple"
shouldBlur={true}
/> />
<StatCard <StatCard
title="Active Accounts" title="Active Accounts"

View File

@@ -1,4 +1,4 @@
from typing import Any, Dict, Generic, List, TypeVar from typing import Generic, List, TypeVar
from pydantic import BaseModel from pydantic import BaseModel

View File

@@ -246,11 +246,6 @@ async def get_account_transactions(
offset=offset, offset=offset,
) )
# Get total count for pagination info
total_transactions = await database_service.get_transaction_count_from_db(
account_id=account_id,
)
data: Union[List[TransactionSummary], List[Transaction]] data: Union[List[TransactionSummary], List[Transaction]]
if summary_only: if summary_only:
@@ -299,9 +294,7 @@ async def get_account_transactions(
@router.put("/accounts/{account_id}") @router.put("/accounts/{account_id}")
async def update_account_details( async def update_account_details(account_id: str, update_data: AccountUpdate) -> dict:
account_id: str, update_data: AccountUpdate
) -> dict:
"""Update account details (currently only display_name)""" """Update account details (currently only display_name)"""
try: try:
# Get current account details # Get current account details

View File

@@ -129,9 +129,7 @@ async def test_backup_connection(test_request: BackupTest) -> dict:
success = await backup_service.test_connection(s3_config) success = await backup_service.test_connection(s3_config)
if not success: if not success:
raise HTTPException( raise HTTPException(status_code=400, detail="S3 connection test failed")
status_code=400, detail="S3 connection test failed"
)
return {"connected": True} return {"connected": True}
@@ -193,9 +191,7 @@ async def backup_operation(operation_request: BackupOperation) -> dict:
success = await backup_service.backup_database(database_path) success = await backup_service.backup_database(database_path)
if not success: if not success:
raise HTTPException( raise HTTPException(status_code=500, detail="Database backup failed")
status_code=500, detail="Database backup failed"
)
return {"operation": "backup", "completed": True} return {"operation": "backup", "completed": True}
@@ -213,9 +209,7 @@ async def backup_operation(operation_request: BackupOperation) -> dict:
) )
if not success: if not success:
raise HTTPException( raise HTTPException(status_code=500, detail="Database restore failed")
status_code=500, detail="Database restore failed"
)
return {"operation": "restore", "completed": True} return {"operation": "restore", "completed": True}
else: else:

View File

@@ -3,7 +3,7 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException from fastapi import APIRouter, BackgroundTasks, HTTPException
from loguru import logger from loguru import logger
from leggen.api.models.sync import SchedulerConfig, SyncRequest from leggen.api.models.sync import SchedulerConfig, SyncRequest, SyncResult, SyncStatus
from leggen.background.scheduler import scheduler from leggen.background.scheduler import scheduler
from leggen.services.sync_service import SyncService from leggen.services.sync_service import SyncService
from leggen.utils.config import config from leggen.utils.config import config
@@ -13,7 +13,7 @@ sync_service = SyncService()
@router.get("/sync/status") @router.get("/sync/status")
async def get_sync_status() -> dict: async def get_sync_status() -> SyncStatus:
"""Get current sync status""" """Get current sync status"""
try: try:
status = await sync_service.get_sync_status() status = await sync_service.get_sync_status()
@@ -78,7 +78,7 @@ async def trigger_sync(
@router.post("/sync/now") @router.post("/sync/now")
async def sync_now(sync_request: Optional[SyncRequest] = None) -> dict: async def sync_now(sync_request: Optional[SyncRequest] = None) -> SyncResult:
"""Run sync synchronously and return results (slower, for testing)""" """Run sync synchronously and return results (slower, for testing)"""
try: try:
if sync_request and sync_request.account_ids: if sync_request and sync_request.account_ids:

View File

@@ -64,11 +64,9 @@ async def get_all_transactions(
search=search, search=search,
) )
data: Union[List[TransactionSummary], List[Transaction]]
if summary_only: if summary_only:
# Return simplified transaction summaries # Return simplified transaction summaries
data = [ data: list[TransactionSummary | Transaction] = [
TransactionSummary( TransactionSummary(
transaction_id=txn["transactionId"], # NEW: stable bank-provided ID transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
internal_transaction_id=txn.get("internalTransactionId"), internal_transaction_id=txn.get("internalTransactionId"),

View File

@@ -5,11 +5,19 @@ import random
import sqlite3 import sqlite3
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, TypedDict
import click import click
class TransactionType(TypedDict):
"""Type definition for transaction type configuration."""
description: str
amount_range: tuple[float, float]
frequency: float
class SampleDataGenerator: class SampleDataGenerator:
"""Generates realistic sample data for testing Leggen.""" """Generates realistic sample data for testing Leggen."""
@@ -42,7 +50,7 @@ class SampleDataGenerator:
}, },
] ]
self.transaction_types = [ self.transaction_types: list[TransactionType] = [
{ {
"description": "Grocery Store", "description": "Grocery Store",
"amount_range": (-150, -20), "amount_range": (-150, -20),
@@ -227,6 +235,8 @@ class SampleDataGenerator:
)[0] )[0]
# Generate transaction amount # Generate transaction amount
min_amount: float
max_amount: float
min_amount, max_amount = transaction_type["amount_range"] min_amount, max_amount = transaction_type["amount_range"]
amount = round(random.uniform(min_amount, max_amount), 2) amount = round(random.uniform(min_amount, max_amount), 2)
@@ -245,7 +255,7 @@ class SampleDataGenerator:
internal_transaction_id = f"int-txn-{random.randint(100000, 999999)}" internal_transaction_id = f"int-txn-{random.randint(100000, 999999)}"
# Create realistic descriptions # Create realistic descriptions
descriptions = { descriptions: dict[str, list[str]] = {
"Grocery Store": [ "Grocery Store": [
"TESCO", "TESCO",
"SAINSBURY'S", "SAINSBURY'S",
@@ -273,7 +283,7 @@ class SampleDataGenerator:
"Transfer to Savings": ["SAVINGS TRANSFER", "INVESTMENT TRANSFER"], "Transfer to Savings": ["SAVINGS TRANSFER", "INVESTMENT TRANSFER"],
} }
specific_descriptions = descriptions.get( specific_descriptions: list[str] = descriptions.get(
transaction_type["description"], [transaction_type["description"]] transaction_type["description"], [transaction_type["description"]]
) )
description = random.choice(specific_descriptions) description = random.choice(specific_descriptions)

View File

@@ -8,8 +8,10 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
import tomli_w
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from leggen.commands.server import create_app
from leggen.utils.config import Config from leggen.utils.config import Config
# Create test config before any imports that might load it # Create test config before any imports that might load it
@@ -27,15 +29,12 @@ _config_data = {
"scheduler": {"sync": {"enabled": True, "hour": 3, "minute": 0}}, "scheduler": {"sync": {"enabled": True, "hour": 3, "minute": 0}},
} }
import tomli_w
with open(_test_config_path, "wb") as f: with open(_test_config_path, "wb") as f:
tomli_w.dump(_config_data, f) tomli_w.dump(_config_data, f)
# Set environment variables to point to test config BEFORE importing the app # Set environment variables to point to test config BEFORE importing the app
os.environ["LEGGEN_CONFIG_FILE"] = str(_test_config_path) os.environ["LEGGEN_CONFIG_FILE"] = str(_test_config_path)
from leggen.commands.server import create_app
def pytest_configure(config): def pytest_configure(config):
"""Pytest hook called before test collection.""" """Pytest hook called before test collection."""
@@ -114,7 +113,9 @@ def mock_auth_token(temp_config_dir):
def fastapi_app(mock_db_path): def fastapi_app(mock_db_path):
"""Create FastAPI test application.""" """Create FastAPI test application."""
# Patch the database path for the app # Patch the database path for the app
with patch("leggen.utils.paths.path_manager.get_database_path", return_value=mock_db_path): with patch(
"leggen.utils.paths.path_manager.get_database_path", return_value=mock_db_path
):
app = create_app() app = create_app()
yield app yield app

View File

@@ -211,10 +211,7 @@ class TestBackupAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert len(data) == 2 assert len(data) == 2
assert ( assert data[0]["key"] == "leggen_backups/database_backup_20250101_120000.db"
data[0]["key"]
== "leggen_backups/database_backup_20250101_120000.db"
)
def test_list_backups_no_config(self, api_client, mock_config): def test_list_backups_no_config(self, api_client, mock_config):
"""Test backup listing with no configuration.""" """Test backup listing with no configuration."""

View File

@@ -157,7 +157,6 @@ class TestTransactionsAPI:
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json()
# Verify the database service was called with correct filters # Verify the database service was called with correct filters
mock_get_transactions.assert_called_once_with( mock_get_transactions.assert_called_once_with(