perf(dashboard): optimize query performance and eliminate redundant requests (#146)

## Why

### Problem

The dashboard page takes ~4 seconds to load for users with many balance
records. Profiling revealed 29 queries across the initial page load + 3
separate API calls, a 576 KB wasted payload from unused bank records,
and a critical 3.7s bottleneck in `BalanceLookup` correlated subqueries.

## What

### Changes

**Query optimizations:**
- Replace correlated `MAX()` subqueries in `BalanceLookup` with
derived-table `joinSub` pattern — **48x faster** (3,693ms → 77ms) on
accounts with thousands of balance records
- Replace `whereHas` (EXISTS subqueries) with JOINs in
`DashboardAnalyticsController` and `CashflowAnalyticsController` — ~3x
faster per query
- Cache encryption check results in `HandleInertiaRequests` middleware
to avoid 2 duplicate queries per request

**Payload & request reduction:**
- Remove dead `banks` query from `DashboardController` (2,365 records,
576 KB never used by frontend)
- Remove duplicate `categories` and `accounts` queries already provided
by middleware shared props
- Consolidate 3 separate `fetch()` API calls into Inertia v2
`Inertia::defer()` props grouped under `'dashboard'` (single follow-up
request with skeleton fallbacks)

**Frontend:**
- Replace `useDashboardData` fetch hook with `usePage()` props +
`<Deferred>` components
- Convert `CashflowSummaryCard` from internal fetch to prop-based
- Use `router.reload({ only: [...] })` for balance update refetch

### Performance summary

| Metric | Before | After |
|--------|--------|-------|
| Initial page queries | 16 | 12 |
| Follow-up HTTP requests | 3 separate API calls | 1 Inertia deferred |
| BalanceLookup time | 3,693ms | 77ms |
| Wasted payload | 576 KB | 0 |

## Verification

### Tests

All 633 feature tests pass, including 40 dashboard/cashflow-specific
tests.
This commit is contained in:
Víctor Falcón 2026-02-24 08:43:48 +01:00 committed by GitHub
parent ce9574aa14
commit ae81e20a66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 403 additions and 165 deletions

View File

@ -153,31 +153,35 @@ class CashflowAnalyticsController extends Controller
private function getTransactionSum(string $userId, Carbon $from, Carbon $to, CategoryType $type): int
{
return Transaction::query()
->where('user_id', $userId)
->whereBetween('transaction_date', [$from, $to])
->where('transactions.user_id', $userId)
->whereBetween('transactions.transaction_date', [$from, $to])
->where(function ($q) use ($type) {
$q->whereHas('category', function ($q) use ($type) {
$q->where('type', $type);
$q->whereExists(function ($sub) use ($type) {
$sub->select(DB::raw(1))
->from('categories')
->whereColumn('categories.id', 'transactions.category_id')
->where('categories.type', $type);
})
->orWhere(function ($q) use ($type) {
$q->whereNull('category_id')
->where('amount', $type === CategoryType::Income ? '>' : '<', 0);
$q->whereNull('transactions.category_id')
->where('transactions.amount', $type === CategoryType::Income ? '>' : '<', 0);
});
})
->sum('amount');
->sum('transactions.amount');
}
private function getCategoryBreakdown(string $userId, Carbon $from, Carbon $to, CategoryType $type)
{
// Get categorized transactions
$categorized = Transaction::query()
->where('user_id', $userId)
->whereBetween('transaction_date', [$from, $to])
->whereHas('category', function ($q) use ($type) {
$q->where('type', $type);
->where('transactions.user_id', $userId)
->whereBetween('transactions.transaction_date', [$from, $to])
->join('categories', function ($join) use ($type) {
$join->on('transactions.category_id', '=', 'categories.id')
->where('categories.type', '=', $type);
})
->select('category_id', DB::raw('sum(amount) as total_amount'))
->groupBy('category_id')
->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount'))
->groupBy('transactions.category_id')
->with('category')
->get()
->map(function ($item) {

View File

@ -369,13 +369,14 @@ class DashboardAnalyticsController extends Controller
private function getCategorySpending(string $userId, Carbon $from, Carbon $to)
{
return Transaction::query()
->where('user_id', $userId)
->whereBetween('transaction_date', [$from, $to])
->whereHas('category', function ($q) {
$q->where('type', CategoryType::Expense);
->where('transactions.user_id', $userId)
->whereBetween('transactions.transaction_date', [$from, $to])
->join('categories', function ($join) {
$join->on('transactions.category_id', '=', 'categories.id')
->where('categories.type', '=', CategoryType::Expense);
})
->select('category_id', DB::raw('sum(amount) as total_amount'))
->groupBy('category_id')
->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount'))
->groupBy('transactions.category_id')
->with('category')
->get()
->map(function ($item) {
@ -444,12 +445,13 @@ class DashboardAnalyticsController extends Controller
private function calculateSpending(Carbon $from, Carbon $to): int
{
$spending = Transaction::query()
->where('user_id', request()->user()->id)
->whereBetween('transaction_date', [$from, $to])
->whereHas('category', function ($q) {
$q->where('type', CategoryType::Expense);
->where('transactions.user_id', request()->user()->id)
->whereBetween('transactions.transaction_date', [$from, $to])
->join('categories', function ($join) {
$join->on('transactions.category_id', '=', 'categories.id')
->where('categories.type', '=', CategoryType::Expense);
})
->sum('amount');
->sum('transactions.amount');
return abs($spending);
}
@ -457,20 +459,22 @@ class DashboardAnalyticsController extends Controller
private function calculateCashFlow(Carbon $from, Carbon $to): array
{
$income = Transaction::query()
->where('user_id', request()->user()->id)
->whereBetween('transaction_date', [$from, $to])
->whereHas('category', function ($q) {
$q->where('type', CategoryType::Income);
->where('transactions.user_id', request()->user()->id)
->whereBetween('transactions.transaction_date', [$from, $to])
->join('categories', function ($join) {
$join->on('transactions.category_id', '=', 'categories.id')
->where('categories.type', '=', CategoryType::Income);
})
->sum('amount');
->sum('transactions.amount');
$expense = Transaction::query()
->where('user_id', request()->user()->id)
->whereBetween('transaction_date', [$from, $to])
->whereHas('category', function ($q) {
$q->where('type', CategoryType::Expense);
->where('transactions.user_id', request()->user()->id)
->whereBetween('transactions.transaction_date', [$from, $to])
->join('categories', function ($join) {
$join->on('transactions.category_id', '=', 'categories.id')
->where('categories.type', '=', CategoryType::Expense);
})
->sum('amount');
->sum('transactions.amount');
return [
'income' => $income,

View File

@ -2,43 +2,232 @@
namespace App\Http\Controllers;
use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Transaction;
use App\Services\BalanceLookup;
use App\Services\ExchangeRateService;
use App\Services\PeriodComparator;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __construct(private ExchangeRateService $exchangeRateService) {}
public function __invoke(Request $request): Response
{
$user = $request->user();
return Inertia::render('dashboard', [
'showEncryptionPrompt' => session('show_encryption_prompt', false),
'netWorthEvolution' => Inertia::defer(fn () => $this->getNetWorthEvolution($request), 'dashboard'),
'topCategories' => Inertia::defer(fn () => $this->getTopCategories($request), 'dashboard'),
'cashflowSummary' => Inertia::defer(fn () => $this->getCashflowSummary($request), 'dashboard'),
]);
}
$categories = Category::query()
->where('user_id', $user->id)
->orderBy('name')
->get(['id', 'name', 'icon', 'color']);
private function getNetWorthEvolution(Request $request): array
{
$user = $request->user();
$now = Carbon::now();
$start = $now->copy()->subMonths(12);
$end = $now->copy();
$userCurrency = $user->currency_code;
$accounts = Account::query()
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
->with(['bank:id,name,logo'])
->get();
$banks = Bank::query()
->where(function ($q) use ($user) {
$q->whereNull('user_id')
->orWhere('user_id', $user->id);
$accountIds = $accounts->pluck('id');
$lookupEnd = Carbon::now()->gt($end) ? Carbon::now() : $end->copy();
$lookup = BalanceLookup::forAccounts($accountIds, $start->copy()->startOfMonth(), $lookupEnd);
$points = [];
$current = $start->copy()->startOfMonth();
$endMonth = $end->copy()->startOfMonth();
while ($current->lte($endMonth)) {
$date = $current->copy()->endOfMonth();
$point = [
'month' => $date->format('Y-m'),
'timestamp' => $date->timestamp,
];
foreach ($accounts as $account) {
$originalBalance = $lookup->getBalanceAt($account->id, $date);
$convertedBalance = $this->convertBalance(
$originalBalance,
$account->currency_code,
$userCurrency,
$date->toDateString(),
);
$point[$account->id] = $convertedBalance;
if ($account->currency_code !== $userCurrency) {
$point[$account->id.'_original'] = [
'amount' => $originalBalance,
'currency_code' => $account->currency_code,
];
}
if ($account->type->supportsInvestedAmount()) {
$investedAmount = $lookup->getInvestedAmountAt($account->id, $date);
$point[$account->id.'_invested'] = $investedAmount !== null
? $this->convertBalance($investedAmount, $account->currency_code, $userCurrency, $date->toDateString())
: null;
}
}
$points[] = $point;
$current->addMonth();
}
$accountsConfig = $accounts->mapWithKeys(function ($account) use ($userCurrency, $lookup, $now) {
$config = [
'id' => $account->id,
'name' => $account->name,
'name_iv' => $account->name_iv,
'encrypted' => $account->encrypted,
'type' => $account->type,
'currency_code' => $account->currency_code,
'bank' => $account->bank,
];
if ($account->type->supportsInvestedAmount()) {
$investedAmount = $lookup->getInvestedAmountAt($account->id, $now);
$config['invested_amount'] = $investedAmount !== null
? $this->convertBalance($investedAmount, $account->currency_code, $userCurrency, $now->toDateString())
: null;
}
return [$account->id => $config];
});
return [
'data' => $points,
'accounts' => $accountsConfig,
'currency_code' => $userCurrency,
];
}
private function getTopCategories(Request $request): array
{
$user = $request->user();
$now = Carbon::now();
$from = $now->copy()->subDays(30);
$to = $now->copy();
$period = new PeriodComparator($from, $to);
$previousPeriod = $period->previous();
$currentSpending = $this->getCategorySpending($user->id, $period->from, $period->to);
$previousSpending = $this->getCategorySpending($user->id, $previousPeriod->from, $previousPeriod->to);
$totalAmount = $currentSpending->sum('amount');
return $currentSpending
->sortByDesc('amount')
->take(10)
->map(function ($item) use ($previousSpending, $totalAmount) {
$previousAmount = $previousSpending->firstWhere('category_id', $item['category_id'])['amount'] ?? 0;
return [
'category' => $item['category'],
'amount' => $item['amount'],
'previous_amount' => $previousAmount,
'total_amount' => $totalAmount,
];
})
->orderBy('name')
->get(['id', 'name', 'logo']);
->values()
->all();
}
return Inertia::render('dashboard', [
'categories' => $categories,
'accounts' => $accounts,
'banks' => $banks,
'showEncryptionPrompt' => session('show_encryption_prompt', false),
]);
private function getCashflowSummary(Request $request): array
{
$user = $request->user();
$now = Carbon::now();
$from = $now->copy()->startOfMonth();
$to = $now->copy()->endOfMonth();
$period = new PeriodComparator($from, $to);
$previousPeriod = $period->previous();
return [
'current' => $this->calculateCashflowSummary($user->id, $period->from, $period->to),
'previous' => $this->calculateCashflowSummary($user->id, $previousPeriod->from, $previousPeriod->to),
];
}
private function getCategorySpending(string $userId, Carbon $from, Carbon $to)
{
return Transaction::query()
->where('transactions.user_id', $userId)
->whereBetween('transactions.transaction_date', [$from, $to])
->join('categories', function ($join) {
$join->on('transactions.category_id', '=', 'categories.id')
->where('categories.type', '=', CategoryType::Expense);
})
->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount'))
->groupBy('transactions.category_id')
->with('category')
->get()
->map(function ($item) {
return [
'category_id' => $item->category_id,
'category' => $item->category,
'amount' => abs($item->total_amount),
];
});
}
private function calculateCashflowSummary(string $userId, Carbon $from, Carbon $to): array
{
$income = $this->getTransactionSum($userId, $from, $to, CategoryType::Income);
$expense = abs($this->getTransactionSum($userId, $from, $to, CategoryType::Expense));
$net = $income - $expense;
$savingsRate = $income > 0 ? round((($income - $expense) / $income) * 100, 1) : 0;
return [
'income' => $income,
'expense' => $expense,
'net' => $net,
'savings_rate' => $savingsRate,
];
}
private function getTransactionSum(string $userId, Carbon $from, Carbon $to, CategoryType $type): int
{
return Transaction::query()
->where('transactions.user_id', $userId)
->whereBetween('transactions.transaction_date', [$from, $to])
->where(function ($q) use ($type) {
$q->whereExists(function ($sub) use ($type) {
$sub->select(DB::raw(1))
->from('categories')
->whereColumn('categories.id', 'transactions.category_id')
->where('categories.type', $type);
})
->orWhere(function ($q) use ($type) {
$q->whereNull('transactions.category_id')
->where('transactions.amount', $type === CategoryType::Income ? '>' : '<', 0);
});
})
->sum('transactions.amount');
}
private function convertBalance(int $balance, string $sourceCurrency, string $targetCurrency, string $date): int
{
if (strtolower($sourceCurrency) === strtolower($targetCurrency)) {
return $balance;
}
return $this->exchangeRateService->convert($sourceCurrency, $targetCurrency, $balance, $date);
}
}

View File

@ -43,12 +43,15 @@ class HandleInertiaRequests extends Middleware
$isDemoAccount = $user?->isDemoAccount() && ! app()->environment('local') ?? false;
$isDemoQuery = $request->query('demo') === '1';
// Cache encryption checks to avoid duplicate queries
$hasEncryptedAccounts = $user?->accounts()->where('encrypted', true)->exists() ?? false;
$hasEncryptedTransactions = $user?->transactions()
->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv'))
->exists() ?? false;
// Clean up encryption data if no encrypted accounts or transactions remain
if (! $request->is('api/*') && $user?->encryption_salt !== null) {
$hasAnyEncryptedData = $user->accounts()->where('encrypted', true)->exists()
|| $user->transactions()->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv'))->exists();
if (! $hasAnyEncryptedData) {
if (! $hasEncryptedAccounts && ! $hasEncryptedTransactions) {
$user->encryptedMessage()->delete();
$user->update(['encryption_salt' => null]);
}
@ -109,11 +112,9 @@ class HandleInertiaRequests extends Middleware
'labels' => fn () => $user ? $user->labels()
->orderBy('name')
->get(['id', 'name', 'color']) : [],
'hasEncryptedAccounts' => $user?->accounts()->where('encrypted', true)->exists() ?? false,
'hasEncryptedAccounts' => $hasEncryptedAccounts,
'hasEncryptionSetup' => $user?->encryption_salt !== null,
'hasEncryptedTransactions' => $user?->transactions()
->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv'))
->exists() ?? false,
'hasEncryptedTransactions' => $hasEncryptedTransactions,
'locale' => app()->getLocale(),
'translations' => $this->getTranslations(),
];

View File

@ -27,9 +27,10 @@ class BalanceLookup
/**
* Preload all balance data for a set of accounts covering the given date range.
*
* Executes exactly 2 queries:
* 1. The most recent balance record before the range start for each account (carry-forward seeds).
* 2. All balance records within the range.
* Executes exactly 3 efficient queries (no correlated subqueries):
* 1. A derived-table join to find the latest balance record before the range start per account.
* 2. A derived-table join to find the latest non-null invested_amount before the range start per account.
* 3. All balance records within the range.
*
* @param Collection<int, string>|array<string> $accountIds
*/
@ -46,32 +47,40 @@ class BalanceLookup
$endDate = $rangeEnd->toDateString();
// Query 1: Get the latest balance record before the range start for each account.
// Uses a correlated subquery to find the max balance_date < rangeStart per account.
// Uses a derived table with GROUP BY + MAX() joined back, avoiding correlated subquery.
$carryForwardRecords = AccountBalance::query()
->whereIn('account_id', $accountIdList)
->where('balance_date', '<', $startDate)
->whereRaw('balance_date = (
SELECT MAX(ab2.balance_date)
FROM account_balances ab2
WHERE ab2.account_id = account_balances.account_id
AND ab2.balance_date < ?
)', [$startDate])
->get(['account_id', 'balance_date', 'balance', 'invested_amount']);
->whereIn('account_balances.account_id', $accountIdList)
->joinSub(
AccountBalance::query()
->selectRaw('account_id, MAX(balance_date) as max_date')
->whereIn('account_id', $accountIdList)
->where('balance_date', '<', $startDate)
->groupBy('account_id'),
'latest',
function ($join) {
$join->on('account_balances.account_id', '=', 'latest.account_id')
->on('account_balances.balance_date', '=', 'latest.max_date');
}
)
->get(['account_balances.account_id', 'account_balances.balance_date', 'account_balances.balance', 'account_balances.invested_amount']);
// For invested_amount carry-forward, we also need the latest non-null invested_amount
// before the range start, which might be on a different date than the latest balance.
// Query 2: Get the latest non-null invested_amount before the range start for each account.
$investedCarryForwardRecords = AccountBalance::query()
->whereIn('account_id', $accountIdList)
->where('balance_date', '<', $startDate)
->whereNotNull('invested_amount')
->whereRaw('balance_date = (
SELECT MAX(ab3.balance_date)
FROM account_balances ab3
WHERE ab3.account_id = account_balances.account_id
AND ab3.balance_date < ?
AND ab3.invested_amount IS NOT NULL
)', [$startDate])
->get(['account_id', 'balance_date', 'invested_amount']);
->whereIn('account_balances.account_id', $accountIdList)
->joinSub(
AccountBalance::query()
->selectRaw('account_id, MAX(balance_date) as max_date')
->whereIn('account_id', $accountIdList)
->where('balance_date', '<', $startDate)
->whereNotNull('invested_amount')
->groupBy('account_id'),
'latest_invested',
function ($join) {
$join->on('account_balances.account_id', '=', 'latest_invested.account_id')
->on('account_balances.balance_date', '=', 'latest_invested.max_date');
}
)
->get(['account_balances.account_id', 'account_balances.balance_date', 'account_balances.invested_amount']);
// Query 2: All balance records within the range.
$rangeRecords = AccountBalance::query()

View File

@ -11,9 +11,7 @@ import { cashflow } from '@/routes';
import { SharedData } from '@/types';
import { __ } from '@/utils/i18n';
import { Link, usePage } from '@inertiajs/react';
import { endOfMonth, format, startOfMonth } from 'date-fns';
import { ArrowRight, TrendingDown, TrendingUp } from 'lucide-react';
import { useEffect, useState } from 'react';
interface CashflowSummary {
income: number;
@ -23,42 +21,20 @@ interface CashflowSummary {
}
interface CashflowSummaryCardProps {
data?: {
current: CashflowSummary;
previous: CashflowSummary;
} | null;
loading?: boolean;
}
export function CashflowSummaryCard({ loading }: CashflowSummaryCardProps) {
export function CashflowSummaryCard({
data,
loading,
}: CashflowSummaryCardProps) {
const { auth } = usePage<SharedData>().props;
const [data, setData] = useState<{
current: CashflowSummary;
previous: CashflowSummary;
} | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const now = new Date();
const from = format(startOfMonth(now), 'yyyy-MM-dd');
const to = format(endOfMonth(now), 'yyyy-MM-dd');
const params = new URLSearchParams({ from, to });
const response = await fetch(
`/api/cashflow/summary?${params.toString()}`,
);
const result = await response.json();
setData(result);
} catch (error) {
console.error('Failed to fetch cashflow summary:', error);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
if (!auth?.user || isLoading || loading) {
if (!auth?.user || loading || !data) {
return (
<Card className="col-span-3">
<CardHeader>
@ -78,10 +54,6 @@ export function CashflowSummaryCard({ loading }: CashflowSummaryCardProps) {
);
}
if (!data) {
return null;
}
const { current } = data;
const isPositiveNet = current.net >= 0;

View File

@ -49,7 +49,7 @@ export interface DashboardData {
isLoading: boolean;
}
function deriveAccountMetrics(
export function deriveAccountMetrics(
netWorthEvolution: NetWorthEvolutionData,
): AccountWithMetrics[] {
const { data, accounts } = netWorthEvolution;

View File

@ -5,31 +5,65 @@ import { TopCategoriesCard } from '@/components/dashboard/top-categories-card';
import HeadingSmall from '@/components/heading-small';
import UnlockMessageDialog from '@/components/unlock-message-dialog';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useDashboardData } from '@/hooks/use-dashboard-data';
import {
type NetWorthEvolutionData,
deriveAccountMetrics,
} from '@/hooks/use-dashboard-data';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { dashboard } from '@/routes';
import { BreadcrumbItem, SharedData } from '@/types';
import { Category } from '@/types/category';
import { __ } from '@/utils/i18n';
import { Head, usePage } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { Deferred, Head, router, usePage } from '@inertiajs/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
interface CashflowSummary {
income: number;
expense: number;
net: number;
savings_rate: number;
}
interface DashboardProps extends SharedData {
showEncryptionPrompt: boolean;
netWorthEvolution?: NetWorthEvolutionData;
topCategories?: Array<{
category: Category;
amount: number;
previous_amount: number;
total_amount: number;
}>;
cashflowSummary?: {
current: CashflowSummary;
previous: CashflowSummary;
};
}
export default function Dashboard() {
const { props } = usePage<DashboardProps>();
const {
netWorthEvolution,
accounts: accountMetrics,
topCategories,
isLoading,
refetch,
} = useDashboardData();
const { isKeySet, encryptedMessageData, fetchEncryptedMessage } =
useEncryptionKey();
const [showUnlockDialog, setShowUnlockDialog] = useState(false);
const netWorthEvolution = props.netWorthEvolution ?? {
data: [],
accounts: {},
currency_code: 'USD',
};
const accountMetrics = useMemo(
() => deriveAccountMetrics(netWorthEvolution),
[netWorthEvolution],
);
const topCategories = props.topCategories ?? [];
const refetch = useCallback(() => {
router.reload({
only: ['netWorthEvolution', 'topCategories', 'cashflowSummary'],
});
}, []);
const breadcrumbs: BreadcrumbItem[] = [
{
title: __('Dashboard'),
@ -82,38 +116,63 @@ export default function Dashboard() {
description={__('Overview of your financial health')}
/>
<NetWorthChartComponent
data={netWorthEvolution}
loading={isLoading}
/>
<Deferred
data="netWorthEvolution"
fallback={
<>
<NetWorthChartComponent
data={{
data: [],
accounts: {},
currency_code: 'USD',
}}
loading={true}
/>
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<AccountBalanceCard
key={i}
// @ts-expect-error - mock data for loading state
account={{}}
loading={true}
/>
))}
</div>
</>
}
>
<NetWorthChartComponent data={netWorthEvolution} />
<div className="grid gap-4 md:grid-cols-2">
{isLoading
? Array.from({ length: 4 }).map((_, i) => (
<AccountBalanceCard
key={i}
// @ts-expect-error - mock data for loading state
account={{}}
loading={true}
/>
))
: accountMetrics.map((account) => (
<AccountBalanceCard
key={account.id}
account={account}
onBalanceUpdated={refetch}
/>
))}
</div>
<div className="grid gap-4 md:grid-cols-2">
{accountMetrics.map((account) => (
<AccountBalanceCard
key={account.id}
account={account}
onBalanceUpdated={refetch}
/>
))}
</div>
</Deferred>
<div className="flex flex-col gap-6">
<TopCategoriesCard
categories={topCategories}
loading={isLoading}
/>
<Deferred
data="topCategories"
fallback={
<TopCategoriesCard categories={[]} loading={true} />
}
>
<TopCategoriesCard categories={topCategories} />
</Deferred>
{props.features.cashflow && (
<CashflowSummaryCard loading={isLoading} />
<Deferred
data="cashflowSummary"
fallback={<CashflowSummaryCard loading={true} />}
>
<CashflowSummaryCard
data={props.cashflowSummary ?? null}
/>
</Deferred>
)}
</div>
</div>