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:
parent
ce9574aa14
commit
ae81e20a66
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export interface DashboardData {
|
|||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function deriveAccountMetrics(
|
||||
export function deriveAccountMetrics(
|
||||
netWorthEvolution: NetWorthEvolutionData,
|
||||
): AccountWithMetrics[] {
|
||||
const { data, accounts } = netWorthEvolution;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue