Compare commits

..

5 Commits

Author SHA1 Message Date
Elisiário Couto
bfb5a7ef76 chore(ci): Bump version to 2025.9.12 2025-09-16 00:14:10 +01:00
copilot-swe-agent[bot]
95b3b93a8a Restore original package.json dev script with VITE_API_URL
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-16 00:12:50 +01:00
Elisiário Couto
9a2199873c Delete frontend/.env.development 2025-09-16 00:12:50 +01:00
copilot-swe-agent[bot]
82a12dadad Complete display_name feature with frontend integration and testing
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-16 00:12:50 +01:00
copilot-swe-agent[bot]
33a7ad5ad2 Implement display_name field with migration and API support
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-16 00:12:50 +01:00
31 changed files with 190 additions and 5394 deletions

View File

@@ -1,4 +1,10 @@
## 2025.9.12 (2025/09/15)
## 2025.9.12 (2025/09/15)
## 2025.9.11 (2025/09/15) ## 2025.9.11 (2025/09/15)
### Bug Fixes ### Bug Fixes

1
frontend/.gitignore vendored
View File

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

View File

@@ -2,35 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="48x48" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Leggen</title> <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="#3B82F6" />
<meta name="msapplication-tap-highlight" content="no" />
<!-- Dynamic theme-color - will be updated by JavaScript -->
<meta name="theme-color" content="#ffffff" id="theme-color-meta" />
<meta name="msapplication-navbutton-color" content="#ffffff" 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="#3B82F6" />
<link rel="shortcut icon" href="/favicon.ico" />
<!-- Manifest -->
<link rel="manifest" href="/manifest.webmanifest" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 B

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 B

View File

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

View File

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

View File

@@ -81,8 +81,8 @@ export default function AccountsOverview() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const updateAccountMutation = useMutation({ const updateAccountMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
apiClient.updateAccount(id, { name }), apiClient.updateAccount(id, { display_name }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] }); queryClient.invalidateQueries({ queryKey: ["accounts"] });
setEditingAccountId(null); setEditingAccountId(null);
@@ -95,14 +95,15 @@ export default function AccountsOverview() {
const handleEditStart = (account: Account) => { const handleEditStart = (account: Account) => {
setEditingAccountId(account.id); setEditingAccountId(account.id);
setEditingName(account.name || ""); // Use display_name if available, otherwise fall back to name
setEditingName(account.display_name || account.name || "");
}; };
const handleEditSave = () => { const handleEditSave = () => {
if (editingAccountId && editingName.trim()) { if (editingAccountId && editingName.trim()) {
updateAccountMutation.mutate({ updateAccountMutation.mutate({
id: editingAccountId, id: editingAccountId,
name: editingName.trim(), display_name: editingName.trim(),
}); });
} }
}; };
@@ -267,7 +268,7 @@ export default function AccountsOverview() {
setEditingName(e.target.value) 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" 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="Account name" placeholder="Custom account name"
name="search" name="search"
autoComplete="off" autoComplete="off"
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -303,7 +304,7 @@ export default function AccountsOverview() {
<div> <div>
<div className="flex items-center space-x-2 min-w-0"> <div className="flex items-center space-x-2 min-w-0">
<h4 className="text-base sm:text-lg font-medium text-foreground truncate"> <h4 className="text-base sm:text-lg font-medium text-foreground truncate">
{account.name || "Unnamed Account"} {account.display_name || account.name || "Unnamed Account"}
</h4> </h4>
<button <button
onClick={() => handleEditStart(account)} onClick={() => handleEditStart(account)}

View File

@@ -1,156 +0,0 @@
import { useEffect, useState } from "react";
import { X, Download, RotateCcw } from "lucide-react";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
interface PWAPromptProps {
onInstall?: () => void;
}
export function PWAInstallPrompt({ onInstall }: PWAPromptProps) {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
// Prevent the mini-infobar from appearing on mobile
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setShowPrompt(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
try {
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
onInstall?.();
}
setDeferredPrompt(null);
setShowPrompt(false);
} catch (error) {
console.error("Error installing PWA:", error);
}
};
const handleDismiss = () => {
setShowPrompt(false);
setDeferredPrompt(null);
};
if (!showPrompt || !deferredPrompt) return null;
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<Download className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
Install Leggen
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Add to your home screen for quick access
</p>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex gap-2">
<button
onClick={handleInstall}
className="flex-1 bg-blue-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Install
</button>
<button
onClick={handleDismiss}
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
Not now
</button>
</div>
</div>
);
}
interface PWAUpdatePromptProps {
updateAvailable: boolean;
onUpdate: () => void;
}
export function PWAUpdatePrompt({ updateAvailable, onUpdate }: PWAUpdatePromptProps) {
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
if (updateAvailable) {
setShowPrompt(true);
}
}, [updateAvailable]);
const handleUpdate = () => {
onUpdate();
setShowPrompt(false);
};
const handleDismiss = () => {
setShowPrompt(false);
};
if (!showPrompt || !updateAvailable) return null;
return (
<div className="fixed top-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<RotateCcw className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
Update Available
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
A new version of Leggen is ready to install
</p>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex gap-2">
<button
onClick={handleUpdate}
className="flex-1 bg-green-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
>
Update Now
</button>
<button
onClick={handleDismiss}
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
Later
</button>
</div>
</div>
);
}

View File

@@ -38,7 +38,7 @@ export function AccountCombobox({
); );
const formatAccountName = (account: Account) => { const formatAccountName = (account: Account) => {
const displayName = account.name || "Unnamed Account"; const displayName = account.display_name || account.name || "Unnamed Account";
return `${displayName} (${account.institution_id})`; return `${displayName} (${account.institution_id})`;
}; };
@@ -89,7 +89,7 @@ export function AccountCombobox({
{accounts.map((account) => ( {accounts.map((account) => (
<CommandItem <CommandItem
key={account.id} key={account.id}
value={`${account.name} ${account.institution_id}`} value={`${account.display_name || account.name} ${account.institution_id}`}
onSelect={() => { onSelect={() => {
onAccountChange(account.id); onAccountChange(account.id);
setOpen(false); setOpen(false);
@@ -105,7 +105,7 @@ export function AccountCombobox({
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium"> <span className="font-medium">
{account.name || "Unnamed Account"} {account.display_name || account.name || "Unnamed Account"}
</span> </span>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{account.institution_id} {account.institution_id}

View File

@@ -10,12 +10,6 @@ interface ThemeContextType {
const ThemeContext = createContext<ThemeContextType | undefined>(undefined); const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// Theme colors for different modes
const THEME_COLORS = {
light: "#ffffff",
dark: "#0f0f23", // Dark background color that matches typical dark themes
} as const;
export function ThemeProvider({ children }: { children: React.ReactNode }) { export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => { const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem("theme") as Theme; const stored = localStorage.getItem("theme") as Theme;
@@ -46,28 +40,6 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
// Add resolved theme class // Add resolved theme class
root.classList.add(resolvedTheme); 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(); updateActualTheme();

View File

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

@@ -41,8 +41,8 @@ export const apiClient = {
updateAccount: async ( updateAccount: async (
id: string, id: string,
updates: AccountUpdate, updates: AccountUpdate,
): Promise<{ id: string; name?: string }> => { ): Promise<{ id: string; display_name?: string }> => {
const response = await api.put<ApiResponse<{ id: string; name?: string }>>( const response = await api.put<ApiResponse<{ id: string; display_name?: string }>>(
`/accounts/${id}`, `/accounts/${id}`,
updates, updates,
); );

View File

@@ -2,25 +2,9 @@ import { createRootRoute, Outlet } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import Sidebar from "../components/Sidebar"; import Sidebar from "../components/Sidebar";
import Header from "../components/Header"; import Header from "../components/Header";
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
import { usePWA } from "../hooks/usePWA";
function RootLayout() { function RootLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false); 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 ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
@@ -40,13 +24,6 @@ function RootLayout() {
<Outlet /> <Outlet />
</main> </main>
</div> </div>
{/* PWA Prompts */}
<PWAInstallPrompt onInstall={handlePWAInstall} />
<PWAUpdatePrompt
updateAvailable={updateAvailable}
onUpdate={handlePWAUpdate}
/>
</div> </div>
); );
} }

View File

@@ -11,6 +11,7 @@ export interface Account {
status: string; status: string;
iban?: string; iban?: string;
name?: string; name?: string;
display_name?: string;
currency?: string; currency?: string;
created: string; created: string;
last_accessed?: string; last_accessed?: string;
@@ -18,7 +19,7 @@ export interface Account {
} }
export interface AccountUpdate { export interface AccountUpdate {
name?: string; display_name?: string;
} }
export interface RawTransactionData { export interface RawTransactionData {

View File

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

View File

@@ -1,88 +1,10 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-vite-plugin"; import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
import { VitePWA } from "vite-plugin-pwa";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [TanStackRouterVite(), react()],
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: "#ffffff",
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: { resolve: {
alias: { alias: {
"@": "/src", "@": "/src",

View File

@@ -24,6 +24,7 @@ class AccountDetails(BaseModel):
status: str status: str
iban: Optional[str] = None iban: Optional[str] = None
name: Optional[str] = None name: Optional[str] = None
display_name: Optional[str] = None
currency: Optional[str] = None currency: Optional[str] = None
created: datetime created: datetime
last_accessed: Optional[datetime] = None last_accessed: Optional[datetime] = None
@@ -36,7 +37,7 @@ class AccountDetails(BaseModel):
class AccountUpdate(BaseModel): class AccountUpdate(BaseModel):
"""Account update model""" """Account update model"""
name: Optional[str] = None display_name: Optional[str] = None
class Config: class Config:
json_encoders = {datetime: lambda v: v.isoformat() if v else None} json_encoders = {datetime: lambda v: v.isoformat() if v else None}

View File

@@ -53,6 +53,7 @@ async def get_all_accounts() -> APIResponse:
status=db_account["status"], status=db_account["status"],
iban=db_account.get("iban"), iban=db_account.get("iban"),
name=db_account.get("name"), name=db_account.get("name"),
display_name=db_account.get("display_name"),
currency=db_account.get("currency"), currency=db_account.get("currency"),
created=db_account["created"], created=db_account["created"],
last_accessed=db_account.get("last_accessed"), last_accessed=db_account.get("last_accessed"),
@@ -112,6 +113,7 @@ async def get_account_details(account_id: str) -> APIResponse:
status=db_account["status"], status=db_account["status"],
iban=db_account.get("iban"), iban=db_account.get("iban"),
name=db_account.get("name"), name=db_account.get("name"),
display_name=db_account.get("display_name"),
currency=db_account.get("currency"), currency=db_account.get("currency"),
created=db_account["created"], created=db_account["created"],
last_accessed=db_account.get("last_accessed"), last_accessed=db_account.get("last_accessed"),
@@ -324,7 +326,7 @@ async def get_account_transactions(
async def update_account_details( async def update_account_details(
account_id: str, update_data: AccountUpdate account_id: str, update_data: AccountUpdate
) -> APIResponse: ) -> APIResponse:
"""Update account details (currently only name)""" """Update account details (currently only display_name)"""
try: try:
# Get current account details # Get current account details
current_account = await database_service.get_account_details_from_db(account_id) current_account = await database_service.get_account_details_from_db(account_id)
@@ -336,16 +338,16 @@ async def update_account_details(
# Prepare updated account data # Prepare updated account data
updated_account_data = current_account.copy() updated_account_data = current_account.copy()
if update_data.name is not None: if update_data.display_name is not None:
updated_account_data["name"] = update_data.name updated_account_data["display_name"] = update_data.display_name
# Persist updated account details # Persist updated account details
await database_service.persist_account_details(updated_account_data) await database_service.persist_account_details(updated_account_data)
return APIResponse( return APIResponse(
success=True, success=True,
data={"id": account_id, "name": update_data.name}, data={"id": account_id, "display_name": update_data.display_name},
message=f"Account {account_id} name updated successfully", message=f"Account {account_id} display name updated successfully",
) )
except HTTPException: except HTTPException:

View File

@@ -215,6 +215,7 @@ class DatabaseService:
await self._migrate_balance_timestamps_if_needed() await self._migrate_balance_timestamps_if_needed()
await self._migrate_null_transaction_ids_if_needed() await self._migrate_null_transaction_ids_if_needed()
await self._migrate_to_composite_key_if_needed() await self._migrate_to_composite_key_if_needed()
await self._migrate_add_display_name_if_needed()
async def _migrate_balance_timestamps_if_needed(self): async def _migrate_balance_timestamps_if_needed(self):
"""Check and migrate balance timestamps if needed""" """Check and migrate balance timestamps if needed"""
@@ -632,6 +633,79 @@ class DatabaseService:
logger.error(f"Composite key migration failed: {e}") logger.error(f"Composite key migration failed: {e}")
raise raise
async def _migrate_add_display_name_if_needed(self):
"""Check and add display_name column to accounts table if needed"""
try:
if await self._check_display_name_migration_needed():
logger.info("Display name column migration needed, starting...")
await self._migrate_add_display_name()
logger.info("Display name column migration completed")
else:
logger.info("Display name column already exists")
except Exception as e:
logger.error(f"Display name column migration failed: {e}")
raise
async def _check_display_name_migration_needed(self) -> bool:
"""Check if display_name column needs to be added to accounts table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Check if accounts table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
)
if not cursor.fetchone():
conn.close()
return False
# Check if display_name column exists
cursor.execute("PRAGMA table_info(accounts)")
columns = cursor.fetchall()
# Check if display_name column exists
has_display_name = any(col[1] == "display_name" for col in columns)
conn.close()
return not has_display_name
except Exception as e:
logger.error(f"Failed to check display_name migration status: {e}")
return False
async def _migrate_add_display_name(self):
"""Add display_name column to accounts table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
logger.info("Adding display_name column to accounts table...")
# Add the display_name column
cursor.execute("""
ALTER TABLE accounts
ADD COLUMN display_name TEXT
""")
conn.commit()
conn.close()
logger.info("Display name column migration completed successfully")
except Exception as e:
logger.error(f"Display name column migration failed: {e}")
raise
def _unix_to_datetime_string(self, unix_timestamp: float) -> str: def _unix_to_datetime_string(self, unix_timestamp: float) -> str:
"""Convert Unix timestamp to datetime string""" """Convert Unix timestamp to datetime string"""
dt = datetime.fromtimestamp(unix_timestamp) dt = datetime.fromtimestamp(unix_timestamp)
@@ -1045,7 +1119,8 @@ class DatabaseService:
currency TEXT, currency TEXT,
created DATETIME, created DATETIME,
last_accessed DATETIME, last_accessed DATETIME,
last_updated DATETIME last_updated DATETIME,
display_name TEXT
)""" )"""
) )
@@ -1060,6 +1135,16 @@ class DatabaseService:
) )
try: try:
# First, check if account exists and preserve display_name
cursor.execute(
"SELECT display_name FROM accounts WHERE id = ?", (account_data["id"],)
)
existing_row = cursor.fetchone()
existing_display_name = existing_row[0] if existing_row else None
# Use existing display_name if not provided in account_data
display_name = account_data.get("display_name", existing_display_name)
# Insert or replace account data # Insert or replace account data
cursor.execute( cursor.execute(
"""INSERT OR REPLACE INTO accounts ( """INSERT OR REPLACE INTO accounts (
@@ -1071,8 +1156,9 @@ class DatabaseService:
currency, currency,
created, created,
last_accessed, last_accessed,
last_updated last_updated,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", display_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
account_data["id"], account_data["id"],
account_data["institution_id"], account_data["institution_id"],
@@ -1083,6 +1169,7 @@ class DatabaseService:
account_data["created"], account_data["created"],
account_data.get("last_accessed"), account_data.get("last_accessed"),
account_data.get("last_updated", account_data["created"]), account_data.get("last_updated", account_data["created"]),
display_name,
), ),
) )
conn.commit() conn.commit()

View File

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

View File

@@ -106,7 +106,8 @@ class SampleDataGenerator:
currency TEXT, currency TEXT,
created DATETIME, created DATETIME,
last_accessed DATETIME, last_accessed DATETIME,
last_updated DATETIME last_updated DATETIME,
display_name TEXT
) )
""") """)
@@ -373,8 +374,8 @@ class SampleDataGenerator:
cursor.execute( cursor.execute(
""" """
INSERT OR REPLACE INTO accounts INSERT OR REPLACE INTO accounts
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated) (id, institution_id, status, iban, name, currency, created, last_accessed, last_updated, display_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
account["id"], account["id"],
@@ -386,6 +387,7 @@ class SampleDataGenerator:
account["created"], account["created"],
account["last_accessed"], account["last_accessed"],
account["last_updated"], account["last_updated"],
None, # display_name is initially None for sample data
), ),
) )

View File

@@ -24,6 +24,8 @@ class TestAccountsAPI:
"institution_id": "REVOLUT_REVOLT21", "institution_id": "REVOLUT_REVOLT21",
"status": "READY", "status": "READY",
"iban": "LT313250081177977789", "iban": "LT313250081177977789",
"name": "Personal Account",
"display_name": None,
"created": "2024-02-13T23:56:00Z", "created": "2024-02-13T23:56:00Z",
"last_accessed": "2025-09-01T09:30:00Z", "last_accessed": "2025-09-01T09:30:00Z",
} }
@@ -80,6 +82,8 @@ class TestAccountsAPI:
"institution_id": "REVOLUT_REVOLT21", "institution_id": "REVOLUT_REVOLT21",
"status": "READY", "status": "READY",
"iban": "LT313250081177977789", "iban": "LT313250081177977789",
"name": "Personal Account",
"display_name": None,
"created": "2024-02-13T23:56:00Z", "created": "2024-02-13T23:56:00Z",
"last_accessed": "2025-09-01T09:30:00Z", "last_accessed": "2025-09-01T09:30:00Z",
} }
@@ -283,3 +287,58 @@ class TestAccountsAPI:
response = api_client.get("/api/v1/accounts/nonexistent") response = api_client.get("/api/v1/accounts/nonexistent")
assert response.status_code == 404 assert response.status_code == 404
def test_update_account_display_name_success(
self, api_client, mock_config, mock_auth_token, mock_db_path
):
"""Test successful update of account display name."""
mock_account = {
"id": "test-account-123",
"institution_id": "REVOLUT_REVOLT21",
"status": "READY",
"iban": "LT313250081177977789",
"name": "Personal Account",
"display_name": None,
"created": "2024-02-13T23:56:00Z",
"last_accessed": "2025-09-01T09:30:00Z",
}
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
return_value=mock_account,
),
patch(
"leggen.api.routes.accounts.database_service.persist_account_details",
return_value=None,
),
):
response = api_client.put(
"/api/v1/accounts/test-account-123",
json={"display_name": "My Custom Account Name"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["id"] == "test-account-123"
assert data["data"]["display_name"] == "My Custom Account Name"
def test_update_account_not_found(
self, api_client, mock_config, mock_auth_token, mock_db_path
):
"""Test updating non-existent account."""
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
return_value=None,
),
):
response = api_client.put(
"/api/v1/accounts/nonexistent",
json={"display_name": "New Name"},
)
assert response.status_code == 404

2
uv.lock generated
View File

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