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:
parent
0a9ca5b606
commit
ce9574aa14
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue