Skip to content

Commit 7d0edcf

Browse files
rootclaude
authored andcommitted
feat: Implement comprehensive UI/UX improvements and bug fixes
This commit implements multiple critical fixes and enhancements: 🎨 UI Improvements: - Replace native dropdowns with accessible Shadcn/UI Select components - Add toast notifications with Sonner for better user feedback - Implement Skeleton loading states across all pages - Improve contrast and accessibility (WCAG 2.1 AA compliant) ⚠️ Bug Fixes: - Fix dropdown visibility issue (white text on white background) - Implement user-friendly error handling for duplicate entries - Add Spanish error messages for database constraint violations - Prevent 500 errors on duplicate portfolio snapshots 📊 Portfolio History Refactor: - Redesign PortfolioSnapshot to support independent entry types - Allow multiple entries per day (TOTAL_INVESTED, PORTFOLIO_VALUE, NET_WORTH, LIQUID_ASSETS) - Remove date+user_id unique constraint, add date+user_id+entry_type constraint - Update dashboard to handle independent series in charts 🔄 Recurring Transactions: - Implement CRON scheduler for recurring transactions (DAILY, WEEKLY, MONTHLY, YEARLY) - Add isRecurring, recurrenceType, nextExecutionDate fields to Transaction entity - Auto-generate transactions with [AUTO] prefix - Configurable schedule via RECURRING_TX_CRON environment variable 🧪 Testing: - Add test profile with create-drop DDL for clean test runs - Exclude DataInitializer from test profile - Tests passing successfully Backend changes: - EntryType and RecurrenceType enums - DuplicateEntryException with RFC 7807 Problem Details - RecurringTransactionScheduler with @EnableScheduling - Updated repositories, services, and controllers Frontend changes: - Shadcn/UI components (Select, Skeleton) - Updated TypeScript interfaces - Enhanced forms with conditional fields - Improved loading states and error handling Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent d3149ac commit 7d0edcf

30 files changed

Lines changed: 1635 additions & 172 deletions

frontend/package-lock.json

Lines changed: 847 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,26 @@
1010
"preview": "vite preview"
1111
},
1212
"dependencies": {
13+
"@radix-ui/react-dialog": "^1.1.15",
14+
"@radix-ui/react-select": "^2.2.6",
15+
"@radix-ui/react-slot": "^1.2.4",
1316
"axios": "^1.13.5",
17+
"class-variance-authority": "^0.7.1",
18+
"clsx": "^2.1.1",
1419
"framer-motion": "^12.34.0",
1520
"jwt-decode": "^4.0.0",
1621
"lucide-react": "^0.563.0",
1722
"react": "^19.2.0",
1823
"react-dom": "^19.2.0",
1924
"react-router-dom": "^7.13.0",
20-
"recharts": "^3.7.0"
25+
"recharts": "^3.7.0",
26+
"sonner": "^2.0.7",
27+
"tailwind-merge": "^3.4.0"
2128
},
2229
"devDependencies": {
2330
"@eslint/js": "^9.39.1",
2431
"@tailwindcss/vite": "^4.1.18",
25-
"@types/node": "^24.10.1",
32+
"@types/node": "^24.10.13",
2633
"@types/react": "^19.2.7",
2734
"@types/react-dom": "^19.2.3",
2835
"@vitejs/plugin-react": "^5.1.1",

frontend/src/App.tsx

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Routes, Route } from 'react-router-dom'
2+
import { Toaster } from 'sonner'
23
import Layout from './components/Layout'
34
import ProtectedRoute from './auth/ProtectedRoute'
45
import Dashboard from './pages/Dashboard'
@@ -11,26 +12,29 @@ import AuthCallback from './pages/AuthCallback'
1112

1213
function App() {
1314
return (
14-
<Routes>
15-
<Route path="/login" element={<LoginPage />} />
16-
<Route path="/auth/callback" element={<AuthCallback />} />
17-
<Route
18-
path="/*"
19-
element={
20-
<ProtectedRoute>
21-
<Layout>
22-
<Routes>
23-
<Route path="/" element={<Dashboard />} />
24-
<Route path="/transactions" element={<Transactions />} />
25-
<Route path="/portfolio" element={<Portfolio />} />
26-
<Route path="/history" element={<HistoryEntry />} />
27-
<Route path="/fire" element={<FirePage />} />
28-
</Routes>
29-
</Layout>
30-
</ProtectedRoute>
31-
}
32-
/>
33-
</Routes>
15+
<>
16+
<Toaster position="top-right" theme="dark" />
17+
<Routes>
18+
<Route path="/login" element={<LoginPage />} />
19+
<Route path="/auth/callback" element={<AuthCallback />} />
20+
<Route
21+
path="/*"
22+
element={
23+
<ProtectedRoute>
24+
<Layout>
25+
<Routes>
26+
<Route path="/" element={<Dashboard />} />
27+
<Route path="/transactions" element={<Transactions />} />
28+
<Route path="/portfolio" element={<Portfolio />} />
29+
<Route path="/history" element={<HistoryEntry />} />
30+
<Route path="/fire" element={<FirePage />} />
31+
</Routes>
32+
</Layout>
33+
</ProtectedRoute>
34+
}
35+
/>
36+
</Routes>
37+
</>
3438
)
3539
}
3640

frontend/src/api.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import axios from 'axios';
2+
import { toast } from 'sonner';
23
import type { Transaction, PortfolioSnapshot, Asset, DashboardSummary, FireProfile, FireProjection, AuthUser } from './types';
34

45
const TOKEN_KEY = 'zesfin_token';
@@ -51,11 +52,41 @@ export const fetchSnapshots = () =>
5152
export const fetchLatestSnapshot = () =>
5253
api.get<PortfolioSnapshot>('/portfolio/snapshots/latest').then(r => r.data);
5354

54-
export const createSnapshot = (snapshot: PortfolioSnapshot) =>
55-
api.post<PortfolioSnapshot>('/portfolio/snapshots', snapshot).then(r => r.data);
56-
57-
export const updateSnapshot = (id: number, snapshot: PortfolioSnapshot) =>
58-
api.put<PortfolioSnapshot>(`/portfolio/snapshots/${id}`, snapshot).then(r => r.data);
55+
export const createSnapshot = async (snapshot: PortfolioSnapshot) => {
56+
try {
57+
const response = await api.post<PortfolioSnapshot>('/portfolio/snapshots', snapshot);
58+
toast.success('Entry created successfully');
59+
return response.data;
60+
} catch (error: any) {
61+
if (error.response?.status === 409) {
62+
const detail = error.response.data.detail || 'Duplicate entry detected';
63+
toast.error(detail, {
64+
description: 'Please choose a different date or entry type',
65+
duration: 5000
66+
});
67+
} else {
68+
toast.error('Failed to create entry');
69+
}
70+
throw error;
71+
}
72+
};
73+
74+
export const updateSnapshot = async (id: number, snapshot: PortfolioSnapshot) => {
75+
try {
76+
const response = await api.put<PortfolioSnapshot>(`/portfolio/snapshots/${id}`, snapshot);
77+
toast.success('Entry updated successfully');
78+
return response.data;
79+
} catch (error: any) {
80+
if (error.response?.status === 409) {
81+
toast.error(error.response.data.detail || 'Duplicate entry detected', {
82+
duration: 5000
83+
});
84+
} else {
85+
toast.error('Failed to update entry');
86+
}
87+
throw error;
88+
}
89+
};
5990

6091
export const deleteSnapshot = (id: number) =>
6192
api.delete(`/portfolio/snapshots/${id}`);
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import * as React from "react"
2+
import * as SelectPrimitive from "@radix-ui/react-select"
3+
import { Check, ChevronDown, ChevronUp } from "lucide-react"
4+
5+
import { cn } from "@/lib/utils"
6+
7+
const Select = SelectPrimitive.Root
8+
9+
const SelectGroup = SelectPrimitive.Group
10+
11+
const SelectValue = SelectPrimitive.Value
12+
13+
const SelectTrigger = React.forwardRef<
14+
React.ElementRef<typeof SelectPrimitive.Trigger>,
15+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
16+
>(({ className, children, ...props }, ref) => (
17+
<SelectPrimitive.Trigger
18+
ref={ref}
19+
className={cn(
20+
"flex h-10 w-full items-center justify-between rounded-md border border-white/[0.08] bg-white/[0.05] px-3 py-2 text-sm text-white ring-offset-background placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
21+
className
22+
)}
23+
{...props}
24+
>
25+
{children}
26+
<SelectPrimitive.Icon asChild>
27+
<ChevronDown className="h-4 w-4 opacity-50" />
28+
</SelectPrimitive.Icon>
29+
</SelectPrimitive.Trigger>
30+
))
31+
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
32+
33+
const SelectScrollUpButton = React.forwardRef<
34+
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
35+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
36+
>(({ className, ...props }, ref) => (
37+
<SelectPrimitive.ScrollUpButton
38+
ref={ref}
39+
className={cn(
40+
"flex cursor-default items-center justify-center py-1",
41+
className
42+
)}
43+
{...props}
44+
>
45+
<ChevronUp className="h-4 w-4" />
46+
</SelectPrimitive.ScrollUpButton>
47+
))
48+
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
49+
50+
const SelectScrollDownButton = React.forwardRef<
51+
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
52+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
53+
>(({ className, ...props }, ref) => (
54+
<SelectPrimitive.ScrollDownButton
55+
ref={ref}
56+
className={cn(
57+
"flex cursor-default items-center justify-center py-1",
58+
className
59+
)}
60+
{...props}
61+
>
62+
<ChevronDown className="h-4 w-4" />
63+
</SelectPrimitive.ScrollDownButton>
64+
))
65+
SelectScrollDownButton.displayName =
66+
SelectPrimitive.ScrollDownButton.displayName
67+
68+
const SelectContent = React.forwardRef<
69+
React.ElementRef<typeof SelectPrimitive.Content>,
70+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
71+
>(({ className, children, position = "popper", ...props }, ref) => (
72+
<SelectPrimitive.Portal>
73+
<SelectPrimitive.Content
74+
ref={ref}
75+
className={cn(
76+
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-white/[0.12] bg-slate-900/95 backdrop-blur-xl text-white shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
77+
position === "popper" &&
78+
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
79+
className
80+
)}
81+
position={position}
82+
{...props}
83+
>
84+
<SelectScrollUpButton />
85+
<SelectPrimitive.Viewport
86+
className={cn(
87+
"p-1",
88+
position === "popper" &&
89+
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
90+
)}
91+
>
92+
{children}
93+
</SelectPrimitive.Viewport>
94+
<SelectScrollDownButton />
95+
</SelectPrimitive.Content>
96+
</SelectPrimitive.Portal>
97+
))
98+
SelectContent.displayName = SelectPrimitive.Content.displayName
99+
100+
const SelectLabel = React.forwardRef<
101+
React.ElementRef<typeof SelectPrimitive.Label>,
102+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
103+
>(({ className, ...props }, ref) => (
104+
<SelectPrimitive.Label
105+
ref={ref}
106+
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
107+
{...props}
108+
/>
109+
))
110+
SelectLabel.displayName = SelectPrimitive.Label.displayName
111+
112+
const SelectItem = React.forwardRef<
113+
React.ElementRef<typeof SelectPrimitive.Item>,
114+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
115+
>(({ className, children, ...props }, ref) => (
116+
<SelectPrimitive.Item
117+
ref={ref}
118+
className={cn(
119+
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-white/[0.1] focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
120+
className
121+
)}
122+
{...props}
123+
>
124+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
125+
<SelectPrimitive.ItemIndicator>
126+
<Check className="h-4 w-4" />
127+
</SelectPrimitive.ItemIndicator>
128+
</span>
129+
130+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
131+
</SelectPrimitive.Item>
132+
))
133+
SelectItem.displayName = SelectPrimitive.Item.displayName
134+
135+
const SelectSeparator = React.forwardRef<
136+
React.ElementRef<typeof SelectPrimitive.Separator>,
137+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
138+
>(({ className, ...props }, ref) => (
139+
<SelectPrimitive.Separator
140+
ref={ref}
141+
className={cn("-mx-1 my-1 h-px bg-white/[0.08]", className)}
142+
{...props}
143+
/>
144+
))
145+
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
146+
147+
export {
148+
Select,
149+
SelectGroup,
150+
SelectValue,
151+
SelectTrigger,
152+
SelectContent,
153+
SelectLabel,
154+
SelectItem,
155+
SelectSeparator,
156+
SelectScrollUpButton,
157+
SelectScrollDownButton,
158+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { cn } from "@/lib/utils"
2+
3+
function Skeleton({
4+
className,
5+
...props
6+
}: React.HTMLAttributes<HTMLDivElement>) {
7+
return (
8+
<div
9+
className={cn("animate-pulse rounded-md bg-white/[0.05]", className)}
10+
{...props}
11+
/>
12+
)
13+
}
14+
15+
export { Skeleton }

frontend/src/lib/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { clsx, type ClassValue } from "clsx"
2+
import { twMerge } from "tailwind-merge"
3+
4+
export function cn(...inputs: ClassValue[]) {
5+
return twMerge(clsx(inputs))
6+
}

frontend/src/pages/Dashboard.tsx

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import {
1212
import { Wallet, TrendingUp, ArrowDownUp, Target } from 'lucide-react'
1313
import { motion } from 'framer-motion'
1414
import Card from '../components/Card'
15+
import { Skeleton } from '@/components/ui/skeleton'
1516
import { fetchDashboardSummary, fetchSnapshots } from '../api'
16-
import type { DashboardSummary, PortfolioSnapshot } from '../types'
17+
import type { DashboardSummary, PortfolioSnapshot, EntryType } from '../types'
1718

1819
const fmt = (n: number) =>
1920
new Intl.NumberFormat('en-EU', {
@@ -40,20 +41,39 @@ export default function Dashboard() {
4041

4142
if (loading) {
4243
return (
43-
<div className="flex items-center justify-center h-64">
44-
<div className="w-8 h-8 rounded-full border-2 border-emerald-500/20 border-t-emerald-400 animate-spin" />
44+
<div className="space-y-8">
45+
<Skeleton className="h-8 w-48 bg-white/[0.05]" />
46+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
47+
{[1,2,3,4].map(i => (
48+
<Skeleton key={i} className="h-24 rounded-2xl bg-white/[0.05]" />
49+
))}
50+
</div>
51+
<Skeleton className="h-96 rounded-2xl bg-white/[0.05]" />
4552
</div>
4653
)
4754
}
4855

49-
const chartData = snapshots.map((s) => ({
50-
date: new Date(s.date).toLocaleDateString('en-US', {
51-
month: 'short',
52-
year: '2-digit',
53-
}),
54-
'Portfolio Value': s.portfolioValue,
55-
'Total Invested': s.totalInvested,
56-
}))
56+
const groupedByType = snapshots.reduce((acc, s) => {
57+
if (!acc[s.entryType]) acc[s.entryType] = []
58+
acc[s.entryType].push(s)
59+
return acc
60+
}, {} as Record<EntryType, PortfolioSnapshot[]>)
61+
62+
const allDates = new Set<string>()
63+
snapshots.forEach(s => allDates.add(s.date))
64+
65+
const chartData = Array.from(allDates)
66+
.sort()
67+
.map(date => {
68+
const invested = groupedByType['TOTAL_INVESTED']?.find(s => s.date === date)
69+
const portfolio = groupedByType['PORTFOLIO_VALUE']?.find(s => s.date === date)
70+
71+
return {
72+
date: new Date(date).toLocaleDateString('en-US', { month: 'short', year: '2-digit' }),
73+
'Total Invested': invested?.value || null,
74+
'Portfolio Value': portfolio?.value || null,
75+
}
76+
})
5777

5878
const yieldPct =
5979
summary && summary.totalInvested > 0

0 commit comments

Comments
 (0)