From 602d19c5c9ca877aa4405ddd188f6133e0336ebb Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 15 Jun 2026 18:55:30 +0100 Subject: [PATCH 01/20] refactor(dashboard): Eliminate duplicate stats requests and cache bypass The dashboard and reports pages were slow because stats-heavy views issued many redundant requests and refetched already-cached data on every visit. Statistics are now shared across the components that display them, so the stats endpoint is requested once per view instead of once per component, with a short client cache and request de-duplication. They refresh automatically when a transaction is added, edited, or deleted. Reports now render from server-computed aggregates immediately and load the detailed transaction data needed for the calendar and trends in the background, and no longer fetch a full previous period just to compare. Initial sign-in data loading no longer throws away a warm cache and loads shared data and transactions at the same time rather than one after another. --- composables/useDataManager.ts | 13 +- composables/useReportData.ts | 62 +++++--- composables/useStatistics.ts | 144 +++++++++++++------ composables/useTransactions.ts | 15 ++ layouts/dashboard.vue | 6 +- tests/composables/useStatisticsCache.test.ts | 110 ++++++++++++++ 6 files changed, 280 insertions(+), 70 deletions(-) create mode 100644 tests/composables/useStatisticsCache.test.ts diff --git a/composables/useDataManager.ts b/composables/useDataManager.ts index e776355..e5f515a 100644 --- a/composables/useDataManager.ts +++ b/composables/useDataManager.ts @@ -29,15 +29,12 @@ export const useDataManager = () => { error.value = null; try { - // Clear existing data to ensure loading state shows properly - const { clearAllData, loadAllData } = useSharedData(); - const { clearTransactions, refreshTransactions } = useTransactions(); - clearAllData(); - clearTransactions(); + const { loadAllData } = useSharedData(); + const { refreshTransactions } = useTransactions(); - // Force reload all data (bypass cache) - await loadAllData(true); - await refreshTransactions(); + // Honor the shared-data cache; logout already clears it for a fresh session. + // Shared data and transactions load concurrently instead of in series. + await Promise.all([loadAllData(false), refreshTransactions()]); // Apply saved language preference via cookie (avoids useI18n context issues) const { configurationsMap } = useSharedData(); diff --git a/composables/useReportData.ts b/composables/useReportData.ts index 5383e49..20a9ee0 100644 --- a/composables/useReportData.ts +++ b/composables/useReportData.ts @@ -115,7 +115,6 @@ const compareEnabled = ref(true); const apiStats = ref(null); const apiStatsPrev = ref(null); const periodTransactions = ref([]); -const prevPeriodTransactions = ref([]); const trailing12Transactions = ref([]); const isLoading = ref(false); const error = ref(null); @@ -226,7 +225,14 @@ async function fetchAllTransactionsInRange( ) ); out.push(...mapped); - if (!resp.data || resp.data.length < limit || page >= resp.last_page || page >= maxPages) break; + if (!resp.data || resp.data.length < limit || page >= resp.last_page || page >= maxPages) { + if (page >= maxPages && resp.last_page && page < resp.last_page) { + console.warn( + `[reports] transaction sweep truncated at ${maxPages} pages (${out.length} rows); some data omitted` + ); + } + break; + } page += 1; } return out; @@ -259,34 +265,46 @@ async function reload(lookups: { isLoading.value = true; error.value = null; - try { - const cur = getRange(selectedPeriod.value, customRange.value); - const prev = previousRange(cur); - const trailingStart = startOfDay(new Date(cur.end.getFullYear(), cur.end.getMonth() - 5, 1)); + const cur = getRange(selectedPeriod.value, customRange.value); + const prev = previousRange(cur); + const trailingStart = startOfDay(new Date(cur.end.getFullYear(), cur.end.getMonth() - 5, 1)); - const [statsResp, prevStatsResp, periodTxs, prevTxs, trailingTxs] = await Promise.all([ + // Load stats first so the stats-driven tabs render without waiting for the + // transaction sweeps below. + try { + const [statsResp, prevStatsResp] = await Promise.all([ fetchStatsForRange(cur.start, cur.end), - fetchStatsForRange(prev.start, prev.end), - fetchAllTransactionsInRange(cur.start, cur.end, lookups), - fetchAllTransactionsInRange(prev.start, prev.end, lookups), - fetchAllTransactionsInRange(trailingStart, cur.end, lookups) + fetchStatsForRange(prev.start, prev.end) ]); - - // Discard if a newer reload superseded this one if (id !== lastReloadId) return; - apiStats.value = statsResp; apiStatsPrev.value = prevStatsResp; - periodTransactions.value = periodTxs; - prevPeriodTransactions.value = prevTxs; - trailing12Transactions.value = trailingTxs; } catch (e) { if (id !== lastReloadId) return; - console.error('[reports] reload failed', e); + console.error('[reports] stats reload failed', e); error.value = e instanceof Error ? e.message : 'Failed to load reports'; } finally { if (id === lastReloadId) isLoading.value = false; } + + // Raw transactions only feed daily granularity (calendar, trends, month + // review, CSV). When the period already spans the trailing six months, reuse + // that fetch as the trailing data instead of running a second sweep. + try { + const periodCoversTrailing = cur.start.getTime() <= trailingStart.getTime(); + const [periodTxs, trailingTxs] = await Promise.all([ + fetchAllTransactionsInRange(cur.start, cur.end, lookups), + periodCoversTrailing + ? Promise.resolve(null) + : fetchAllTransactionsInRange(trailingStart, cur.end, lookups) + ]); + if (id !== lastReloadId) return; + periodTransactions.value = periodTxs; + trailing12Transactions.value = trailingTxs ?? periodTxs; + } catch (e) { + if (id !== lastReloadId) return; + console.error('[reports] transaction detail reload failed', e); + } } export const useReportData = () => { @@ -574,8 +592,12 @@ export const useReportData = () => { } }); - // First-time payees: compare period payees vs prior period payees - const prevPayees = new Set(prevPeriodTransactions.value.map((t) => t.party)); + // First-time payees: compare period payees vs prior-period payees from the + // previous-period stats (party charts), avoiding a second raw-transaction fetch. + const prevPayees = new Set([ + ...(apiStatsPrev.value?.charts?.party_spending || []).map((p) => p.name), + ...(apiStatsPrev.value?.charts?.party_income || []).map((p) => p.name) + ]); const firstTimers = Array.from( new Set(periodTransactions.value.filter((t) => !prevPayees.has(t.party)).map((t) => t.party)) ); diff --git a/composables/useStatistics.ts b/composables/useStatistics.ts index 3b8e9ba..735317c 100644 --- a/composables/useStatistics.ts +++ b/composables/useStatistics.ts @@ -178,6 +178,66 @@ export interface CustomFilters { const customFilters = ref(null); +const currentStatistics = ref(null); + +// Client-side cache + in-flight dedup for the /stats endpoint, keyed by resolved +// request params. Collapses the many identical requests that fire when several +// components mount useStatistics() at once. Backend caches /stats for 5 min. +const STATS_CACHE_DURATION = 60 * 1000; + +interface StatsCacheEntry { + data: WalletStatistics; + fetchedAt: number; +} + +const statsCache = new Map(); +const statsInFlight = new Map>(); + +const clearStatsCache = () => { + statsCache.clear(); + statsInFlight.clear(); +}; + +const presetMap: Record = { + all_time: 'all_time', + current_week: 'current_week', + current_month: 'current_month', + '90d': 'last_3_months' +}; + +const buildStatsParams = (walletId: number | null, period: string): Record => { + const params: Record = {}; + + if (period === 'custom' && customFilters.value) { + if (customFilters.value.startDate) { + params.start_date = customFilters.value.startDate; + } + if (customFilters.value.endDate) { + params.end_date = customFilters.value.endDate; + } + if (customFilters.value.walletIds.length > 0) { + params.wallet_ids = customFilters.value.walletIds.join(','); + } + } else { + if (presetMap[period]) { + params.preset = presetMap[period]; + } + if (walletId) { + params.wallet_ids = String(walletId); + } + } + + return params; +}; + +const statsCacheKey = (params: Record): string => + Object.keys(params) + .sort() + .map((k) => `${k}=${params[k]}`) + .join('&'); + +let statsWatcherRegistered = false; + export const useStatistics = () => { const { transactions } = useTransactions(); const { wallets } = useWallets(); @@ -670,41 +730,40 @@ export const useStatistics = () => { walletId: number | null, period: string ): Promise => { - const { api } = await import('~/services/api'); - - // Map period to preset - const presetMap: Record = { - all_time: 'all_time', - current_week: 'current_week', - current_month: 'current_month', - '90d': 'last_3_months' - }; + const params = buildStatsParams(walletId, period); + const key = statsCacheKey(params); - const params: Record = {}; + const cached = statsCache.get(key); + if (cached && Date.now() - cached.fetchedAt < STATS_CACHE_DURATION) { + return cached.data; + } - // Use custom filters if set, otherwise use preset - if (period === 'custom' && customFilters.value) { - if (customFilters.value.startDate) { - params.start_date = customFilters.value.startDate; - } - if (customFilters.value.endDate) { - params.end_date = customFilters.value.endDate; - } - if (customFilters.value.walletIds.length > 0) { - params.wallet_ids = customFilters.value.walletIds.join(','); - } - } else { - if (presetMap[period]) { - params.preset = presetMap[period]; - } - if (walletId) { - params.wallet_ids = String(walletId); - } + const inFlight = statsInFlight.get(key); + if (inFlight) { + return inFlight; } - const response = await api.stats.fetch(params); - const data = response.data; + const request = (async (): Promise => { + const { api } = await import('~/services/api'); + const response = await api.stats.fetch(params); + const result = transformStatsResponse(response.data, walletId, period); + statsCache.set(key, { data: result, fetchedAt: Date.now() }); + return result; + })(); + + statsInFlight.set(key, request); + try { + return await request; + } finally { + statsInFlight.delete(key); + } + }; + const transformStatsResponse = ( + data: any, + walletId: number | null, + period: string + ): WalletStatistics => { const activity = data.activity || {}; const frequency = activity.frequency || {}; const incomeGrowth = data.comparisons?.previous_period?.income_change_percent || 0; @@ -908,8 +967,6 @@ export const useStatistics = () => { } }; - const currentStatistics = ref(null); - const updateCurrentStatistics = async () => { try { isLoading.value = true; @@ -924,15 +981,21 @@ export const useStatistics = () => { } }; - // Watch for changes in selected wallet, period, custom filters, AND when the underlying data becomes available - watch( - [selectedWalletId, currentPeriod, customFilters, transactions, wallets], - updateCurrentStatistics, - { + const refreshStatistics = async () => { + clearStatsCache(); + await updateCurrentStatistics(); + }; + + // Statistics come from the /stats endpoint, so the values only change when the + // filters change. Watching the transactions/wallets arrays would refire on every + // mutation for no benefit. Register a single watcher for the whole app. + if (!statsWatcherRegistered) { + statsWatcherRegistered = true; + watch([selectedWalletId, currentPeriod, customFilters], updateCurrentStatistics, { immediate: true, - deep: true - } - ); + deep: false + }); + } const formatCurrency = (amount: number, currency: string = 'USD'): string => { const rounded = Math.round(amount * 100) / 100; @@ -987,6 +1050,7 @@ export const useStatistics = () => { availablePeriods: AVAILABLE_PERIODS, getStatistics, + refreshStatistics, setSelectedWallet, setPeriod, setCustomFilters, diff --git a/composables/useTransactions.ts b/composables/useTransactions.ts index 84e6eb1..45a1c60 100644 --- a/composables/useTransactions.ts +++ b/composables/useTransactions.ts @@ -56,6 +56,18 @@ async function loadDependencies() { } } +// Drop cached statistics and refetch after a mutation so the dashboard reflects +// the change. Dynamic import avoids a circular dependency with useStatistics. +async function refreshStatsAfterMutation() { + if (typeof window === 'undefined') return; + try { + const { useStatistics } = await import('~/composables/useStatistics'); + await useStatistics().refreshStatistics(); + } catch (err) { + console.error('Error refreshing statistics after mutation:', err); + } +} + // Monotonic counter used to ensure only the latest fetch's response is // applied to state. Prevents race conditions when a user types quickly // or changes filters rapidly and older in-flight requests resolve after @@ -237,6 +249,7 @@ export const useTransactions = () => { transactions.value = [frontendTransaction, ...transactions.value]; totalItems.value += 1; console.log('Transaction created and added to local state'); + void refreshStatsAfterMutation(); } } catch (err: unknown) { console.error('Error adding transaction:', err); @@ -334,6 +347,7 @@ export const useTransactions = () => { transactions.value[index] = frontendTransaction; } console.log('Transaction updated in local state'); + void refreshStatsAfterMutation(); } } catch (err) { console.error('Error updating transaction:', err); @@ -357,6 +371,7 @@ export const useTransactions = () => { // Remove from local state transactions.value = transactions.value.filter((t) => t.id !== id); totalItems.value = Math.max(0, totalItems.value - 1); + void refreshStatsAfterMutation(); } catch (err) { console.error('Error deleting transaction:', err); error.value = extractApiErrors(err); diff --git a/layouts/dashboard.vue b/layouts/dashboard.vue index cf535cf..f8d5695 100644 --- a/layouts/dashboard.vue +++ b/layouts/dashboard.vue @@ -25,7 +25,7 @@ import LearningModal from '@/components/modals/LearningModal.vue'; import { useAuth } from '@/composables/useAuth'; import { useSidebar } from '@/composables/useSidebar'; -const { fetchUser } = useAuth(); +const { user, fetchUser } = useAuth(); const { sidebarCollapsed } = useSidebar(); const showLearningModal = ref(false); @@ -39,7 +39,9 @@ provide('learningModal', { }); onMounted(() => { - fetchUser(); + if (!user.value) { + fetchUser(); + } }); diff --git a/tests/composables/useStatisticsCache.test.ts b/tests/composables/useStatisticsCache.test.ts new file mode 100644 index 0000000..78e78cc --- /dev/null +++ b/tests/composables/useStatisticsCache.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ref, computed } from 'vue'; +import type { FrontendTransaction } from '~/types/transaction'; +import { useStatistics } from '~/composables/useStatistics'; + +const mockTransactions = ref([]); +const mockWallets = ref([]); + +vi.mock('~/composables/useTransactions', () => ({ + useTransactions: () => ({ transactions: mockTransactions }) +})); + +vi.mock('~/composables/useWallets', () => ({ + useWallets: () => ({ wallets: mockWallets }) +})); + +vi.mock('~/composables/useSharedData', () => ({ + useSharedData: () => ({ getDefaultCurrency: computed(() => 'USD') }) +})); + +vi.mock('~/utils/auth', () => ({ + checkAuth: () => true +})); + +const statsData = { + overview: { + net_cash_flow: 100, + total_income: 500, + total_expenses: 400, + savings_rate: 20, + avg_monthly_income: 500, + avg_monthly_expenses: 400 + }, + activity: { + transaction_count: 5, + unique_parties: 3, + frequency: { per_day: 1, per_week: 7, per_month: 30 }, + busiest_day: 'Monday' + }, + comparisons: { previous_period: { income_change_percent: 0 } }, + top_categories: { income: [], expenses: [] }, + charts: { + party_income: [], + party_spending: [], + income_sources: [], + category_spending: [], + monthly_cash_flow: [] + } +}; + +const fetchMock = vi.fn(); + +vi.mock('~/services/api', () => ({ + api: { + stats: { + fetch: (...args: any[]) => fetchMock(...args) + } + } +})); + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('useStatistics - request dedup & cache', () => { + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue({ data: statsData }); + }); + + it('issues a single /stats request when multiple components mount it with the same params', async () => { + // Three components each call useStatistics(); only the first registers the watcher. + useStatistics(); + useStatistics(); + useStatistics(); + await flush(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('deduplicates concurrent identical requests and serves repeats from cache', async () => { + const stats = useStatistics(); + await flush(); + fetchMock.mockClear(); + + await Promise.all([ + stats.getStatistics(null, 'all_time'), + stats.getStatistics(null, 'all_time'), + stats.getStatistics(null, 'all_time') + ]); + // Same params as the watcher already fetched: served from cache, no new request. + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('fetches again for a different param set', async () => { + const stats = useStatistics(); + await flush(); + fetchMock.mockClear(); + + await stats.getStatistics(7, 'current_month'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('refreshStatistics clears the cache and refetches', async () => { + const stats = useStatistics(); + await flush(); + fetchMock.mockClear(); + + await stats.refreshStatistics(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); From ecc0cce1b868d5128ccdbc6ca7b9bfe0d1be98ef Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 16 Jun 2026 14:05:49 +0100 Subject: [PATCH 02/20] feat(dashboard): Load dashboard stats progressively by section The dashboard waited for one large statistics response before anything appeared, so the first load felt slow. It now requests the statistics in sections and fills the dashboard in as each arrives: the headline totals paint first from the cheapest section, then the category breakdown, then the insights, each keeping its own skeleton until its data lands. Repeat requests and concurrent consumers still share one fetch per section. --- composables/useStatistics.ts | 131 ++++++++++++++++++- pages/dashboard/index.vue | 13 +- services/api/statsApi.ts | 12 ++ tests/composables/useStatisticsCache.test.ts | 47 ++++--- 4 files changed, 169 insertions(+), 34 deletions(-) diff --git a/composables/useStatistics.ts b/composables/useStatistics.ts index 735317c..79f6420 100644 --- a/composables/useStatistics.ts +++ b/composables/useStatistics.ts @@ -193,9 +193,68 @@ interface StatsCacheEntry { const statsCache = new Map(); const statsInFlight = new Map>(); +// Section load priority: cheap overview first, then heavier breakdowns. +const STATS_SECTION_ORDER = [ + 'overview', + 'categories', + 'parties', + 'activity', + 'comparisons', + 'cashflow' +]; +const sectionCache = new Map(); +const sectionInFlight = new Map>(); +const loadedSections = ref>(new Set()); +let statsRunId = 0; + +// Share one import: parallel section loads must not race concurrent dynamic imports. +let apiModulePromise: Promise | null = null; +const getStatsApi = () => { + if (!apiModulePromise) { + apiModulePromise = import('~/services/api').then((m) => m.api); + } + return apiModulePromise; +}; + const clearStatsCache = () => { statsCache.clear(); statsInFlight.clear(); + sectionCache.clear(); + sectionInFlight.clear(); + loadedSections.value = new Set(); +}; + +const fetchSection = async ( + walletId: number | null, + period: string, + section: string +): Promise => { + const params = { ...buildStatsParams(walletId, period), section }; + const key = statsCacheKey(params); + + const cached = sectionCache.get(key); + if (cached && Date.now() - cached.fetchedAt < STATS_CACHE_DURATION) { + return cached.data; + } + + const inFlight = sectionInFlight.get(key); + if (inFlight) { + return inFlight; + } + + const request = (async () => { + const api = await getStatsApi(); + const response = await api.stats.fetch(params); + sectionCache.set(key, { data: response.data, fetchedAt: Date.now() }); + return response.data; + })(); + + sectionInFlight.set(key, request); + try { + return await request; + } finally { + sectionInFlight.delete(key); + } }; const presetMap: Record = { @@ -967,20 +1026,76 @@ export const useStatistics = () => { } }; - const updateCurrentStatistics = async () => { + const calculateAllSections = async (walletId: number | null, period: string, runId: number) => { try { - isLoading.value = true; - error.value = null; - currentStatistics.value = await getStatistics(selectedWalletId.value, currentPeriod.value); + const stats = await calculateStatistics(walletId, period); + if (runId !== statsRunId) return; + currentStatistics.value = stats; + loadedSections.value = new Set(STATS_SECTION_ORDER); } catch (err) { console.error('Error loading current statistics:', err); - error.value = 'Failed to load statistics'; - currentStatistics.value = null; - } finally { + if (runId === statsRunId) error.value = 'Failed to load statistics'; + } + }; + + const updateCurrentStatistics = async () => { + const runId = ++statsRunId; + const walletId = selectedWalletId.value; + const period = currentPeriod.value; + + isLoading.value = true; + error.value = null; + loadedSections.value = new Set(); + + if (!USE_API_STATISTICS) { + await calculateAllSections(walletId, period, runId); + if (runId === statsRunId) isLoading.value = false; + return; + } + + const merged: any = {}; + const applySection = (section: string, data: any) => { + if (runId !== statsRunId || !data) return; + if (data.charts) { + merged.charts = { ...(merged.charts || {}), ...data.charts }; + } + for (const k of Object.keys(data)) { + if (k !== 'charts') merged[k] = data[k]; + } + loadedSections.value = new Set(loadedSections.value).add(section); + if (merged.overview) { + currentStatistics.value = transformStatsResponse(merged, walletId, period); + } + }; + + try { + applySection('overview', await fetchSection(walletId, period, 'overview')); + if (runId !== statsRunId) return; isLoading.value = false; + + await Promise.all( + STATS_SECTION_ORDER.filter((s) => s !== 'overview').map((section) => + fetchSection(walletId, period, section) + .then((data) => applySection(section, data)) + .catch((err) => { + console.error(`[stats] section "${section}" failed`, err); + if (runId === statsRunId) { + loadedSections.value = new Set(loadedSections.value).add(section); + } + }) + ) + ); + } catch (err) { + if (runId !== statsRunId) return; + console.warn('API statistics failed, falling back to client-side calculation:', err); + await calculateAllSections(walletId, period, runId); + } finally { + if (runId === statsRunId) isLoading.value = false; } }; + const isSectionLoaded = (section: string): boolean => loadedSections.value.has(section); + const refreshStatistics = async () => { clearStatsCache(); await updateCurrentStatistics(); @@ -1046,6 +1161,8 @@ export const useStatistics = () => { error, currentStatistics, + loadedSections, + isSectionLoaded, availableWallets, availablePeriods: AVAILABLE_PERIODS, diff --git a/pages/dashboard/index.vue b/pages/dashboard/index.vue index c727bea..be5c8a8 100644 --- a/pages/dashboard/index.vue +++ b/pages/dashboard/index.vue @@ -11,7 +11,7 @@