perf(accounts): replace client-side API calls with Inertia deferred prop (#144)

## Why

The `/accounts` page was making two extra client-side API calls via the
`useDashboardData()` hook:
- `/api/dashboard/net-worth-evolution` (~14.5s in dev)
- `/api/dashboard/top-categories` (~119ms, completely unused on this
page)

The backend logic itself is fast (~34ms), but the full middleware stack
cost of extra HTTP roundtrips in the dev environment was adding
significant overhead.

## What

### Backend (`AccountController.php`)
- Added `ExchangeRateService` constructor injection
- Added `Inertia::defer()` prop `accountMetrics` to the `index()` action
- Added `getAccountMetrics()`, `formatMonth()`, `convertBalance()`
private methods
- Uses `BalanceLookup::forAccounts()` for efficient batch loading (2 SQL
queries regardless of account count)

### Frontend (`Accounts/Index.tsx`)
- Removed `useDashboardData()` hook import and usage
- Added `accountMetrics` as an optional deferred prop (undefined until
resolved)
- Loading state derived from `!accountMetrics`, which naturally drives
the existing `loading` prop on `AccountListCard` (skeleton UI already
existed)
- Added `handleBalanceUpdated` callback using `router.reload({ only:
['accountMetrics'] })` for targeted refresh

## Verification

### Tests
- 3 new Pest tests covering deferred prop behavior:
- `accounts index defers account metrics` — verifies metrics are missing
from initial response, present after `loadDeferredProps()`, with correct
balance values
- `accounts index deferred metrics includes invested amount for
investment accounts` — verifies invested amount for investment account
types
- `accounts index deferred metrics returns null invested amount for
non-investment accounts` — verifies non-investment accounts get null
invested amount
- All 15 tests in `AccountControllerTest.php` pass
This commit is contained in:
Víctor Falcón 2026-02-23 20:08:41 +01:00 committed by GitHub
parent 0a9ca5b606
commit ce9574aa14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 350 additions and 12 deletions

View File

@ -5,6 +5,11 @@ namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Category;
use App\Models\User;
use App\Services\BalanceLookup;
use App\Services\ExchangeRateService;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;
@ -14,6 +19,8 @@ class AccountController extends Controller
{
use AuthorizesRequests;
public function __construct(private ExchangeRateService $exchangeRateService) {}
public function index(Request $request): Response
{
$user = $request->user();
@ -27,9 +34,94 @@ class AccountController extends Controller
return Inertia::render('Accounts/Index', [
'accounts' => $accounts,
'accountMetrics' => Inertia::defer(fn () => $this->getAccountMetrics($user, $accounts)),
]);
}
/**
* Compute per-account balance metrics: current balance, previous month balance, and 12-month sparkline history.
*
* @param Collection<int, Account> $accounts
* @return array<string, array{currentBalance: int, previousBalance: int, diff: int, investedAmount: int|null, history: list<array{date: string, value: int, investedAmount?: int|null}>}>
*/
private function getAccountMetrics(User $user, Collection $accounts): array
{
$userCurrency = $user->currency_code;
$now = Carbon::now();
$rangeStart = $now->copy()->subMonths(12)->startOfMonth();
$accountIds = $accounts->pluck('id');
$lookup = BalanceLookup::forAccounts($accountIds, $rangeStart, $now->copy());
$metrics = [];
foreach ($accounts as $account) {
$history = [];
$current = $rangeStart->copy();
$endMonth = $now->copy()->startOfMonth();
while ($current->lte($endMonth)) {
$date = $current->copy()->endOfMonth();
$originalBalance = $lookup->getBalanceAt($account->id, $date);
$convertedBalance = $this->convertBalance($originalBalance, $account->currency_code, $userCurrency, $date->toDateString());
$point = [
'date' => $this->formatMonth($date),
'value' => $convertedBalance,
];
if ($account->type->supportsInvestedAmount()) {
$investedAmount = $lookup->getInvestedAmountAt($account->id, $date);
$point['investedAmount'] = $investedAmount !== null
? $this->convertBalance($investedAmount, $account->currency_code, $userCurrency, $date->toDateString())
: null;
}
$history[] = $point;
$current->addMonth();
}
$currentBalance = end($history)['value'] ?? 0;
$previousBalance = count($history) > 1 ? $history[count($history) - 2]['value'] : 0;
$investedAmount = null;
if ($account->type->supportsInvestedAmount()) {
$rawInvested = $lookup->getInvestedAmountAt($account->id, $now);
$investedAmount = $rawInvested !== null
? $this->convertBalance($rawInvested, $account->currency_code, $userCurrency, $now->toDateString())
: null;
}
$metrics[$account->id] = [
'currentBalance' => $currentBalance,
'previousBalance' => $previousBalance,
'diff' => $currentBalance - $previousBalance,
'investedAmount' => $investedAmount,
'history' => $history,
];
}
return $metrics;
}
private function formatMonth(Carbon $date): string
{
$isCurrentYear = $date->year === Carbon::now()->year;
return $isCurrentYear
? $date->format('M')
: $date->format("M 'y");
}
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);
}
public function show(Request $request, Account $account): Response
{
$this->authorize('view', $account);

View File

@ -1,13 +1,13 @@
import { index } from '@/actions/App/Http/Controllers/AccountController';
import { AccountListCard } from '@/components/accounts/account-list-card';
import HeadingSmall from '@/components/heading-small';
import { useDashboardData } from '@/hooks/use-dashboard-data';
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { BreadcrumbItem } from '@/types';
import { Account, AccountType } from '@/types/account';
import { __ } from '@/utils/i18n';
import { Head } from '@inertiajs/react';
import { useMemo } from 'react';
import { Head, router } from '@inertiajs/react';
import { useCallback, useMemo } from 'react';
const breadcrumbs: BreadcrumbItem[] = [
{
@ -26,16 +26,29 @@ const ACCOUNT_TYPE_ORDER: AccountType[] = [
'others',
];
interface Props {
accounts: Account[];
interface AccountMetrics {
currentBalance: number;
previousBalance: number;
diff: number;
investedAmount: number | null;
history: Array<{
date: string;
value: number;
investedAmount?: number | null;
}>;
}
export default function AccountsIndex({ accounts }: Props) {
const { accounts: accountMetrics, isLoading, refetch } = useDashboardData();
interface Props {
accounts: Account[];
accountMetrics?: Record<string, AccountMetrics>;
}
const accountsWithMetrics = useMemo(() => {
export default function AccountsIndex({ accounts, accountMetrics }: Props) {
const isLoading = !accountMetrics;
const accountsWithMetrics: AccountWithMetrics[] = useMemo(() => {
return accounts.map((account) => {
const metrics = accountMetrics.find((m) => m.id === account.id);
const metrics = accountMetrics?.[account.id];
return {
...account,
currentBalance: metrics?.currentBalance ?? 0,
@ -48,7 +61,7 @@ export default function AccountsIndex({ accounts }: Props) {
}, [accounts, accountMetrics]);
const groupedAccounts = useMemo(() => {
const groups: Record<AccountType, typeof accountsWithMetrics> = {
const groups: Record<AccountType, AccountWithMetrics[]> = {
checking: [],
savings: [],
investment: [],
@ -70,6 +83,10 @@ export default function AccountsIndex({ accounts }: Props) {
return groups;
}, [accountsWithMetrics]);
const handleBalanceUpdated = useCallback(() => {
router.reload({ only: ['accountMetrics'] });
}, []);
return (
<AppSidebarLayout breadcrumbs={breadcrumbs}>
<Head title={__('Accounts')} />
@ -90,14 +107,14 @@ export default function AccountsIndex({ accounts }: Props) {
key={account.id}
account={account}
loading={isLoading}
onBalanceUpdated={refetch}
onBalanceUpdated={handleBalanceUpdated}
/>
));
})}
</div>
{accounts.length === 0 && !isLoading && (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
<div className="text-muted-foreground flex h-[300px] items-center justify-center">
{__(
'No accounts found. Add your first account in Settings.',
)}

View File

@ -0,0 +1,140 @@
<?php
use App\Enums\AccountType;
use App\Models\Account;
use App\Models\Bank;
use App\Models\User;
use function Pest\Laravel\actingAs;
it('can view accounts page', function () {
$user = User::factory()->onboarded()->create();
actingAs($user);
$page = visit('/accounts');
$page->assertSee('Accounts')
->assertSee('View and manage your bank accounts')
->assertNoJavascriptErrors();
});
it('shows empty state when no accounts exist', function () {
$user = User::factory()->onboarded()->create();
actingAs($user);
$page = visit('/accounts');
$page->assertSee('Accounts')
->waitForText('No accounts found')
->assertNoJavascriptErrors();
});
it('shows account cards for existing accounts', function () {
$user = User::factory()->onboarded()->create();
$bank = Bank::factory()->create(['name' => 'Test Bank', 'logo' => null]);
Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'name' => 'My Checking',
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
actingAs($user);
$page = visit('/accounts');
$page->navigate('/accounts', ['waitUntil' => 'domcontentloaded'])->wait(2);
$page->assertSee('Accounts')
->waitForText('My Checking')
->assertSee('Test Bank')
->assertNoJavascriptErrors();
});
it('shows multiple accounts grouped by type', function () {
$user = User::factory()->onboarded()->create();
$bank = Bank::factory()->create(['name' => 'Bank One', 'logo' => null]);
Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'name' => 'Daily Checking',
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'name' => 'Rainy Day Savings',
'type' => AccountType::Savings,
'currency_code' => 'USD',
]);
actingAs($user);
$page = visit('/accounts');
$page->navigate('/accounts', ['waitUntil' => 'domcontentloaded'])->wait(2);
$page->assertSee('Accounts')
->waitForText('Daily Checking')
->assertSee('Rainy Day Savings')
->assertNoJavascriptErrors();
});
it('can navigate to account details page', function () {
$user = User::factory()->onboarded()->create();
$bank = Bank::factory()->create(['name' => 'Nav Bank', 'logo' => null]);
Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'name' => 'Navigable Account',
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
actingAs($user);
$page = visit('/accounts');
$page->navigate('/accounts', ['waitUntil' => 'domcontentloaded'])->wait(2);
$page->waitForText('Navigable Account')
->click('Details →')
->wait(2)
->assertSee('Update balance')
->assertSee('Nav Bank')
->assertNoJavascriptErrors();
});
it('does not show other users accounts', function () {
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->onboarded()->create();
$bank = Bank::factory()->create(['name' => 'Shared Bank', 'logo' => null]);
Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'name' => 'My Own Account',
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
Account::factory()->create([
'user_id' => $otherUser->id,
'bank_id' => $bank->id,
'name' => 'Someone Elses Account',
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
actingAs($user);
$page = visit('/accounts');
$page->navigate('/accounts', ['waitUntil' => 'domcontentloaded'])->wait(2);
$page->waitForText('My Own Account')
->assertDontSee('Someone Elses Account')
->assertNoJavascriptErrors();
});

View File

@ -194,6 +194,95 @@ test('account balance evolution denies access to other users accounts', function
$response->assertForbidden();
});
test('accounts index defers account metrics', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now()->subMonthNoOverflow()->startOfMonth(),
'balance' => 100000,
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now()->startOfMonth(),
'balance' => 150000,
]);
$response = $this->get(route('accounts.list'));
$response->assertOk()
->assertInertia(fn ($page) => $page
->component('Accounts/Index')
->has('accounts', 1)
->missing('accountMetrics')
->loadDeferredProps(fn ($reload) => $reload
->has('accountMetrics')
->has("accountMetrics.{$account->id}")
->has("accountMetrics.{$account->id}.currentBalance")
->has("accountMetrics.{$account->id}.previousBalance")
->has("accountMetrics.{$account->id}.diff")
->has("accountMetrics.{$account->id}.history")
->where("accountMetrics.{$account->id}.currentBalance", 150000)
->where("accountMetrics.{$account->id}.previousBalance", 100000)
->where("accountMetrics.{$account->id}.diff", 50000)
)
);
});
test('accounts index deferred metrics includes invested amount for investment accounts', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Investment,
'currency_code' => 'USD',
]);
AccountBalance::factory()->withInvestedAmount(80000)->create([
'account_id' => $account->id,
'balance_date' => now()->startOfMonth(),
'balance' => 120000,
]);
$response = $this->get(route('accounts.list'));
$response->assertOk()
->assertInertia(fn ($page) => $page
->component('Accounts/Index')
->missing('accountMetrics')
->loadDeferredProps(fn ($reload) => $reload
->where("accountMetrics.{$account->id}.investedAmount", 80000)
->where("accountMetrics.{$account->id}.currentBalance", 120000)
)
);
});
test('accounts index deferred metrics returns null invested amount for non-investment accounts', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now()->startOfMonth(),
'balance' => 100000,
]);
$response = $this->get(route('accounts.list'));
$response->assertOk()
->assertInertia(fn ($page) => $page
->missing('accountMetrics')
->loadDeferredProps(fn ($reload) => $reload
->where("accountMetrics.{$account->id}.investedAmount", null)
)
);
});
test('account show includes bank information', function () {
$bank = Bank::factory()->create([
'name' => 'Test Bank',