mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-29 07:29:03 +00:00
Compare commits
3 Commits
2025.9.12
...
4c953a5a60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c953a5a60 | ||
|
|
b1e562737b | ||
|
|
7521c77f82 |
@@ -1,10 +1,4 @@
|
||||
|
||||
## 2025.9.12 (2025/09/15)
|
||||
|
||||
|
||||
## 2025.9.12 (2025/09/15)
|
||||
|
||||
|
||||
## 2025.9.11 (2025/09/15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -2,9 +2,35 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Leggen</title>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="description" content="Personal finance management application" />
|
||||
<meta name="application-name" content="Leggen" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Leggen" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="msapplication-config" content="/browserconfig.xml" />
|
||||
<meta name="msapplication-TileColor" content="#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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
4995
frontend/package-lock.json
generated
4995
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -40,13 +40,16 @@
|
||||
"@tanstack/router-vite-plugin": "^1.131.36",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vite-pwa/assets-generator": "^1.0.1",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"sharp": "^0.34.3",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2"
|
||||
"vite": "^7.1.2",
|
||||
"vite-plugin-pwa": "^1.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
frontend/public/apple-touch-icon-180x180.png
Normal file
BIN
frontend/public/apple-touch-icon-180x180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 550 B |
9
frontend/public/browserconfig.xml
Normal file
9
frontend/public/browserconfig.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/pwa-192x192.png"/>
|
||||
<TileColor>#3B82F6</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 473 B |
BIN
frontend/public/maskable-icon-512x512.png
Normal file
BIN
frontend/public/maskable-icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/public/pwa-192x192.png
Normal file
BIN
frontend/public/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 676 B |
BIN
frontend/public/pwa-512x512.png
Normal file
BIN
frontend/public/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/pwa-64x64.png
Normal file
BIN
frontend/public/pwa-64x64.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 352 B |
4
frontend/public/robots.txt
Normal file
4
frontend/public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: /sitemap.xml
|
||||
4
frontend/pwa-assets.config.json
Normal file
4
frontend/pwa-assets.config.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"preset": "minimal-2023",
|
||||
"images": ["public/favicon.svg"]
|
||||
}
|
||||
@@ -81,8 +81,8 @@ export default function AccountsOverview() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateAccountMutation = useMutation({
|
||||
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
|
||||
apiClient.updateAccount(id, { display_name }),
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||
apiClient.updateAccount(id, { name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
setEditingAccountId(null);
|
||||
@@ -95,15 +95,14 @@ export default function AccountsOverview() {
|
||||
|
||||
const handleEditStart = (account: Account) => {
|
||||
setEditingAccountId(account.id);
|
||||
// Use display_name if available, otherwise fall back to name
|
||||
setEditingName(account.display_name || account.name || "");
|
||||
setEditingName(account.name || "");
|
||||
};
|
||||
|
||||
const handleEditSave = () => {
|
||||
if (editingAccountId && editingName.trim()) {
|
||||
updateAccountMutation.mutate({
|
||||
id: editingAccountId,
|
||||
display_name: editingName.trim(),
|
||||
name: editingName.trim(),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -268,7 +267,7 @@ export default function AccountsOverview() {
|
||||
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"
|
||||
placeholder="Account name"
|
||||
name="search"
|
||||
autoComplete="off"
|
||||
onKeyDown={(e) => {
|
||||
@@ -304,7 +303,7 @@ export default function AccountsOverview() {
|
||||
<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"}
|
||||
{account.name || "Unnamed Account"}
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => handleEditStart(account)}
|
||||
|
||||
156
frontend/src/components/PWAPrompts.tsx
Normal file
156
frontend/src/components/PWAPrompts.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Download, RotateCcw } from "lucide-react";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
interface PWAPromptProps {
|
||||
onInstall?: () => void;
|
||||
}
|
||||
|
||||
export function PWAInstallPrompt({ onInstall }: PWAPromptProps) {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
// Prevent the mini-infobar from appearing on mobile
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
setShowPrompt(true);
|
||||
};
|
||||
|
||||
window.addEventListener("beforeinstallprompt", handler);
|
||||
|
||||
return () => window.removeEventListener("beforeinstallprompt", handler);
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
try {
|
||||
await deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
if (outcome === "accepted") {
|
||||
onInstall?.();
|
||||
}
|
||||
|
||||
setDeferredPrompt(null);
|
||||
setShowPrompt(false);
|
||||
} catch (error) {
|
||||
console.error("Error installing PWA:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
|
||||
if (!showPrompt || !deferredPrompt) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<Download className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Install Leggen
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Add to your home screen for quick access
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="flex-1 bg-blue-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PWAUpdatePromptProps {
|
||||
updateAvailable: boolean;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export function PWAUpdatePrompt({ updateAvailable, onUpdate }: PWAUpdatePromptProps) {
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateAvailable) {
|
||||
setShowPrompt(true);
|
||||
}
|
||||
}, [updateAvailable]);
|
||||
|
||||
const handleUpdate = () => {
|
||||
onUpdate();
|
||||
setShowPrompt(false);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
};
|
||||
|
||||
if (!showPrompt || !updateAvailable) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<RotateCcw className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Update Available
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
A new version of Leggen is ready to install
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
className="flex-1 bg-green-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
Update Now
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export function AccountCombobox({
|
||||
);
|
||||
|
||||
const formatAccountName = (account: Account) => {
|
||||
const displayName = account.display_name || account.name || "Unnamed Account";
|
||||
const displayName = account.name || "Unnamed Account";
|
||||
return `${displayName} (${account.institution_id})`;
|
||||
};
|
||||
|
||||
@@ -89,7 +89,7 @@ export function AccountCombobox({
|
||||
{accounts.map((account) => (
|
||||
<CommandItem
|
||||
key={account.id}
|
||||
value={`${account.display_name || account.name} ${account.institution_id}`}
|
||||
value={`${account.name} ${account.institution_id}`}
|
||||
onSelect={() => {
|
||||
onAccountChange(account.id);
|
||||
setOpen(false);
|
||||
@@ -105,7 +105,7 @@ export function AccountCombobox({
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{account.display_name || account.name || "Unnamed Account"}
|
||||
{account.name || "Unnamed Account"}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{account.institution_id}
|
||||
|
||||
@@ -10,6 +10,12 @@ interface ThemeContextType {
|
||||
|
||||
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 }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem("theme") as Theme;
|
||||
@@ -40,6 +46,28 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Add resolved theme class
|
||||
root.classList.add(resolvedTheme);
|
||||
|
||||
// Update theme-color meta tags for PWA status bar
|
||||
const themeColor = THEME_COLORS[resolvedTheme];
|
||||
|
||||
// Update theme-color meta tag
|
||||
const themeColorMeta = document.getElementById("theme-color-meta") as HTMLMetaElement;
|
||||
if (themeColorMeta) {
|
||||
themeColorMeta.content = themeColor;
|
||||
}
|
||||
|
||||
// Update Microsoft tile color
|
||||
const msThemeColorMeta = document.getElementById("ms-theme-color-meta") as HTMLMetaElement;
|
||||
if (msThemeColorMeta) {
|
||||
msThemeColorMeta.content = themeColor;
|
||||
}
|
||||
|
||||
// Update Apple status bar style for better iOS integration
|
||||
const appleStatusBarMeta = document.getElementById("apple-status-bar-meta") as HTMLMetaElement;
|
||||
if (appleStatusBarMeta) {
|
||||
// Use 'black-translucent' for dark theme, 'default' for light theme
|
||||
appleStatusBarMeta.content = resolvedTheme === "dark" ? "black-translucent" : "default";
|
||||
}
|
||||
};
|
||||
|
||||
updateActualTheme();
|
||||
|
||||
37
frontend/src/hooks/usePWA.ts
Normal file
37
frontend/src/hooks/usePWA.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface PWAUpdate {
|
||||
updateAvailable: boolean;
|
||||
updateSW: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePWA(): PWAUpdate {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [updateSW, setUpdateSW] = useState<() => Promise<void>>(() => async () => {});
|
||||
|
||||
useEffect(() => {
|
||||
// Check if SW registration is available
|
||||
if ("serviceWorker" in navigator) {
|
||||
// Import the registerSW function
|
||||
import("virtual:pwa-register").then(({ registerSW }) => {
|
||||
const updateSWFunction = registerSW({
|
||||
onNeedRefresh() {
|
||||
setUpdateAvailable(true);
|
||||
setUpdateSW(() => updateSWFunction);
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log("App ready to work offline");
|
||||
},
|
||||
});
|
||||
}).catch(() => {
|
||||
// PWA not available in development mode or when disabled
|
||||
console.log("PWA registration not available");
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
updateAvailable,
|
||||
updateSW,
|
||||
};
|
||||
}
|
||||
@@ -41,8 +41,8 @@ export const apiClient = {
|
||||
updateAccount: async (
|
||||
id: string,
|
||||
updates: AccountUpdate,
|
||||
): Promise<{ id: string; display_name?: string }> => {
|
||||
const response = await api.put<ApiResponse<{ id: string; display_name?: string }>>(
|
||||
): Promise<{ id: string; name?: string }> => {
|
||||
const response = await api.put<ApiResponse<{ id: string; name?: string }>>(
|
||||
`/accounts/${id}`,
|
||||
updates,
|
||||
);
|
||||
|
||||
@@ -2,9 +2,25 @@ import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import Sidebar from "../components/Sidebar";
|
||||
import Header from "../components/Header";
|
||||
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
|
||||
import { usePWA } from "../hooks/usePWA";
|
||||
|
||||
function RootLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { updateAvailable, updateSW } = usePWA();
|
||||
|
||||
const handlePWAInstall = () => {
|
||||
console.log("PWA installed successfully");
|
||||
};
|
||||
|
||||
const handlePWAUpdate = async () => {
|
||||
try {
|
||||
await updateSW();
|
||||
console.log("PWA updated successfully");
|
||||
} catch (error) {
|
||||
console.error("Error updating PWA:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
@@ -24,6 +40,13 @@ function RootLayout() {
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* PWA Prompts */}
|
||||
<PWAInstallPrompt onInstall={handlePWAInstall} />
|
||||
<PWAUpdatePrompt
|
||||
updateAvailable={updateAvailable}
|
||||
onUpdate={handlePWAUpdate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface Account {
|
||||
status: string;
|
||||
iban?: string;
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
currency?: string;
|
||||
created: string;
|
||||
last_accessed?: string;
|
||||
@@ -19,7 +18,7 @@ export interface Account {
|
||||
}
|
||||
|
||||
export interface AccountUpdate {
|
||||
display_name?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface RawTransactionData {
|
||||
|
||||
1
frontend/src/vite-env.d.ts
vendored
1
frontend/src/vite-env.d.ts
vendored
@@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
@@ -1,10 +1,88 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [TanStackRouterVite(), react()],
|
||||
plugins: [
|
||||
TanStackRouterVite(),
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: ["favicon.ico", "apple-touch-icon-180x180.png", "maskable-icon-512x512.png", "robots.txt"],
|
||||
manifest: {
|
||||
name: "Leggen",
|
||||
short_name: "Leggen",
|
||||
description: "Personal finance management application",
|
||||
theme_color: "#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: {
|
||||
alias: {
|
||||
"@": "/src",
|
||||
|
||||
@@ -24,7 +24,6 @@ class AccountDetails(BaseModel):
|
||||
status: str
|
||||
iban: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
currency: Optional[str] = None
|
||||
created: datetime
|
||||
last_accessed: Optional[datetime] = None
|
||||
@@ -37,7 +36,7 @@ class AccountDetails(BaseModel):
|
||||
class AccountUpdate(BaseModel):
|
||||
"""Account update model"""
|
||||
|
||||
display_name: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
|
||||
|
||||
@@ -53,7 +53,6 @@ async def get_all_accounts() -> APIResponse:
|
||||
status=db_account["status"],
|
||||
iban=db_account.get("iban"),
|
||||
name=db_account.get("name"),
|
||||
display_name=db_account.get("display_name"),
|
||||
currency=db_account.get("currency"),
|
||||
created=db_account["created"],
|
||||
last_accessed=db_account.get("last_accessed"),
|
||||
@@ -113,7 +112,6 @@ async def get_account_details(account_id: str) -> APIResponse:
|
||||
status=db_account["status"],
|
||||
iban=db_account.get("iban"),
|
||||
name=db_account.get("name"),
|
||||
display_name=db_account.get("display_name"),
|
||||
currency=db_account.get("currency"),
|
||||
created=db_account["created"],
|
||||
last_accessed=db_account.get("last_accessed"),
|
||||
@@ -326,7 +324,7 @@ async def get_account_transactions(
|
||||
async def update_account_details(
|
||||
account_id: str, update_data: AccountUpdate
|
||||
) -> APIResponse:
|
||||
"""Update account details (currently only display_name)"""
|
||||
"""Update account details (currently only name)"""
|
||||
try:
|
||||
# Get current account details
|
||||
current_account = await database_service.get_account_details_from_db(account_id)
|
||||
@@ -338,16 +336,16 @@ async def update_account_details(
|
||||
|
||||
# Prepare updated account data
|
||||
updated_account_data = current_account.copy()
|
||||
if update_data.display_name is not None:
|
||||
updated_account_data["display_name"] = update_data.display_name
|
||||
if update_data.name is not None:
|
||||
updated_account_data["name"] = update_data.name
|
||||
|
||||
# Persist updated account details
|
||||
await database_service.persist_account_details(updated_account_data)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={"id": account_id, "display_name": update_data.display_name},
|
||||
message=f"Account {account_id} display name updated successfully",
|
||||
data={"id": account_id, "name": update_data.name},
|
||||
message=f"Account {account_id} name updated successfully",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
|
||||
@@ -215,7 +215,6 @@ class DatabaseService:
|
||||
await self._migrate_balance_timestamps_if_needed()
|
||||
await self._migrate_null_transaction_ids_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):
|
||||
"""Check and migrate balance timestamps if needed"""
|
||||
@@ -633,79 +632,6 @@ class DatabaseService:
|
||||
logger.error(f"Composite key migration failed: {e}")
|
||||
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:
|
||||
"""Convert Unix timestamp to datetime string"""
|
||||
dt = datetime.fromtimestamp(unix_timestamp)
|
||||
@@ -1119,8 +1045,7 @@ class DatabaseService:
|
||||
currency TEXT,
|
||||
created DATETIME,
|
||||
last_accessed DATETIME,
|
||||
last_updated DATETIME,
|
||||
display_name TEXT
|
||||
last_updated DATETIME
|
||||
)"""
|
||||
)
|
||||
|
||||
@@ -1135,16 +1060,6 @@ class DatabaseService:
|
||||
)
|
||||
|
||||
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
|
||||
cursor.execute(
|
||||
"""INSERT OR REPLACE INTO accounts (
|
||||
@@ -1156,9 +1071,8 @@ class DatabaseService:
|
||||
currency,
|
||||
created,
|
||||
last_accessed,
|
||||
last_updated,
|
||||
display_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
last_updated
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
account_data["id"],
|
||||
account_data["institution_id"],
|
||||
@@ -1169,7 +1083,6 @@ class DatabaseService:
|
||||
account_data["created"],
|
||||
account_data.get("last_accessed"),
|
||||
account_data.get("last_updated", account_data["created"]),
|
||||
display_name,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "leggen"
|
||||
version = "2025.9.12"
|
||||
version = "2025.9.11"
|
||||
description = "An Open Banking CLI"
|
||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||
requires-python = "~=3.13.0"
|
||||
|
||||
@@ -106,8 +106,7 @@ class SampleDataGenerator:
|
||||
currency TEXT,
|
||||
created DATETIME,
|
||||
last_accessed DATETIME,
|
||||
last_updated DATETIME,
|
||||
display_name TEXT
|
||||
last_updated DATETIME
|
||||
)
|
||||
""")
|
||||
|
||||
@@ -374,8 +373,8 @@ class SampleDataGenerator:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO accounts
|
||||
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated, display_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
account["id"],
|
||||
@@ -387,7 +386,6 @@ class SampleDataGenerator:
|
||||
account["created"],
|
||||
account["last_accessed"],
|
||||
account["last_updated"],
|
||||
None, # display_name is initially None for sample data
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -24,8 +24,6 @@ class TestAccountsAPI:
|
||||
"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",
|
||||
}
|
||||
@@ -82,8 +80,6 @@ class TestAccountsAPI:
|
||||
"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",
|
||||
}
|
||||
@@ -287,58 +283,3 @@ class TestAccountsAPI:
|
||||
response = api_client.get("/api/v1/accounts/nonexistent")
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user