Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ See `backend/app/db/schema.sql`. Key tables:
## API Endpoints
OpenAPI: `backend/app/openapi.yaml`
- Auth: `/auth/register`, `/auth/login`, `/auth/refresh`
- Dashboard: `/dashboard/summary`, `/dashboard/multi-account-overview`
- Expenses: CRUD `/expenses`
- Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay`
- Reminders: CRUD `/reminders`, trigger `/reminders/run`
Expand All @@ -70,6 +71,8 @@ OpenAPI: `backend/app/openapi.yaml`
## MVP UI/UX Plan
- Auth screens: register/login.
- Dashboard:
- Multi-account overview cards with combined totals and per-account net flow.
- Account filter for consolidated or single-account financial view.
- Monthly spend chart, category breakdown donut.
- Upcoming bills list with due dates and pay status.
- AI budget suggestion card.
Expand Down
72 changes: 70 additions & 2 deletions app/src/__tests__/Dashboard.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,49 @@ jest.mock('@/components/ui/button', () => ({
}));

const getDashboardSummaryMock = jest.fn();
const getMultiAccountOverviewMock = jest.fn();
jest.mock('@/api/dashboard', () => ({
getDashboardSummary: (...args: unknown[]) => getDashboardSummaryMock(...args),
getMultiAccountOverview: (...args: unknown[]) => getMultiAccountOverviewMock(...args),
}));

describe('Dashboard integration', () => {
beforeEach(() => {
jest.clearAllMocks();
getMultiAccountOverviewMock.mockResolvedValue({
period: { month: '2026-02' },
aggregated: {
monthly_income: 3200,
monthly_expenses: 550,
net_flow: 2650,
upcoming_bills_total: 49.99,
upcoming_bills_count: 1,
account_count: 2,
},
accounts: [
{
account_key: 'USD',
summary: {
net_flow: 1900,
monthly_income: 2200,
monthly_expenses: 300,
upcoming_bills_total: 49.99,
upcoming_bills_count: 1,
},
},
{
account_key: 'EUR',
summary: {
net_flow: 750,
monthly_income: 1000,
monthly_expenses: 250,
upcoming_bills_total: 0,
upcoming_bills_count: 0,
},
},
],
errors: [],
});
});

it('renders summary, transactions and upcoming bills from backend payload', async () => {
Expand Down Expand Up @@ -74,8 +110,8 @@ describe('Dashboard integration', () => {
await waitFor(() => expect(getDashboardSummaryMock).toHaveBeenCalled());

expect(screen.getByText(/financial dashboard/i)).toBeInTheDocument();
expect(screen.getByText(/salary/i)).toBeInTheDocument();
expect(screen.getByText(/internet/i)).toBeInTheDocument();
expect(await screen.findByText(/salary/i)).toBeInTheDocument();
expect(await screen.findByText(/internet/i)).toBeInTheDocument();
expect(screen.getByText(/category breakdown/i)).toBeInTheDocument();
});

Expand Down Expand Up @@ -139,4 +175,36 @@ describe('Dashboard integration', () => {
fireEvent.change(screen.getByLabelText(/dashboard month/i), { target: { value: '2026-01' } });
await waitFor(() => expect(getDashboardSummaryMock).toHaveBeenLastCalledWith('2026-01'));
});

it('shows multi-account combined totals and per-account overview', async () => {
const currentMonth = new Date().toISOString().slice(0, 7);
getDashboardSummaryMock.mockResolvedValue({
period: { month: '2026-02' },
summary: {
net_flow: 2650,
monthly_income: 3200,
monthly_expenses: 550,
upcoming_bills_total: 49.99,
upcoming_bills_count: 1,
},
recent_transactions: [],
upcoming_bills: [],
category_breakdown: [],
errors: [],
});

render(
<MemoryRouter initialEntries={['/dashboard']}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</MemoryRouter>,
);

await waitFor(() => expect(getMultiAccountOverviewMock).toHaveBeenCalledWith(currentMonth, undefined));
expect(screen.getByRole('heading', { name: /account overview/i })).toBeInTheDocument();
expect((await screen.findAllByText('USD')).length).toBeGreaterThan(0);
expect((await screen.findAllByText('EUR')).length).toBeGreaterThan(0);
expect(await screen.findByText(/2 account\(s\)/i)).toBeInTheDocument();
});
});
37 changes: 37 additions & 0 deletions app/src/api/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,44 @@ export type DashboardSummary = {
errors?: string[];
};

export type MultiAccountOverview = {
period: { month: string };
aggregated: {
monthly_income: number;
monthly_expenses: number;
net_flow: number;
upcoming_bills_total: number;
upcoming_bills_count: number;
account_count: number;
};
accounts: Array<{
account_key: string;
summary: {
net_flow: number;
monthly_income: number;
monthly_expenses: number;
upcoming_bills_total: number;
upcoming_bills_count: number;
};
errors?: string[];
}>;
errors?: string[];
};

export async function getDashboardSummary(month?: string): Promise<DashboardSummary> {
const query = month ? `?month=${encodeURIComponent(month)}` : '';
return api<DashboardSummary>(`/dashboard/summary${query}`);
}

export async function getMultiAccountOverview(
month?: string,
accountKeys?: string[],
): Promise<MultiAccountOverview> {
const params = new URLSearchParams();
if (month) params.set('month', month);
if (accountKeys && accountKeys.length > 0) {
params.set('account_keys', accountKeys.join(','));
}
const query = params.toString();
return api<MultiAccountOverview>(`/dashboard/multi-account-overview${query ? `?${query}` : ''}`);
}
75 changes: 71 additions & 4 deletions app/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,14 @@ import {
AlertTriangle,
Calendar,
Plus,
Landmark,
} from 'lucide-react';
import { getDashboardSummary, type DashboardSummary } from '@/api/dashboard';
import {
getDashboardSummary,
getMultiAccountOverview,
type DashboardSummary,
type MultiAccountOverview,
} from '@/api/dashboard';
import { useNavigate } from 'react-router-dom';
import { formatMoney } from '@/lib/currency';

Expand All @@ -30,24 +36,33 @@ function currency(n: number, code?: string) {
export function Dashboard() {
const navigate = useNavigate();
const [data, setData] = useState<DashboardSummary | null>(null);
const [overview, setOverview] = useState<MultiAccountOverview | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7));
const [selectedAccount, setSelectedAccount] = useState('ALL');

useEffect(() => {
(async () => {
setLoading(true);
setError(null);
try {
const res = await getDashboardSummary(month);
setData(res);
const [summaryRes, overviewRes] = await Promise.all([
getDashboardSummary(month),
getMultiAccountOverview(
month,
selectedAccount === 'ALL' ? undefined : [selectedAccount],
),
]);
setData(summaryRes);
setOverview(overviewRes);
} catch (error: unknown) {
setError(error instanceof Error ? error.message : 'Failed to load dashboard');
} finally {
setLoading(false);
}
})();
}, [month]);
}, [month, selectedAccount]);

const summary = useMemo(() => {
if (!data) {
Expand Down Expand Up @@ -100,6 +115,8 @@ export function Dashboard() {
const transactions = data?.recent_transactions ?? [];
const upcomingBills = data?.upcoming_bills ?? [];
const categoryBreakdown = data?.category_breakdown ?? [];
const accountCards = overview?.accounts ?? [];
const accountCount = overview?.aggregated?.account_count ?? 0;

return (
<div className="page-wrap">
Expand Down Expand Up @@ -131,6 +148,56 @@ export function Dashboard() {
</div>
</div>

<FinancialCard variant="financial" className="fade-in-up mb-6">
<FinancialCardHeader>
<div className="flex items-center justify-between gap-3">
<div>
<FinancialCardTitle className="section-title">Account Overview</FinancialCardTitle>
<FinancialCardDescription>
Combined totals across {accountCount} account(s) for {month}
</FinancialCardDescription>
</div>
<div className="flex items-center gap-2">
<Landmark className="w-4 h-4 text-muted-foreground" />
<label className="sr-only" htmlFor="dashboard-account">Dashboard account</label>
<select
id="dashboard-account"
aria-label="Dashboard account"
className="input h-9 min-w-[170px]"
value={selectedAccount}
onChange={(event) => setSelectedAccount(event.target.value)}
>
<option value="ALL">All Accounts</option>
{accountCards.map((account) => (
<option key={account.account_key} value={account.account_key}>
{account.account_key}
</option>
))}
</select>
</div>
</div>
</FinancialCardHeader>
<FinancialCardContent>
{accountCards.length === 0 ? (
<div className="text-sm text-muted-foreground">No account data available for this period.</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{accountCards.map((account) => (
<div key={account.account_key} className="card p-3">
<div className="text-xs uppercase tracking-wide text-muted-foreground mb-1">{account.account_key}</div>
<div className="text-sm text-foreground font-medium">
Net {currency(account.summary.net_flow, account.account_key)}
</div>
<div className="text-xs text-muted-foreground">
In {currency(account.summary.monthly_income, account.account_key)} · Out {currency(account.summary.monthly_expenses, account.account_key)}
</div>
</div>
))}
</div>
)}
</FinancialCardContent>
</FinancialCard>

{error && (
<div className="error mb-6">{error}. Showing empty fallback state.</div>
)}
Expand Down
Loading