From f3211b7ee72a7e8149c714f43b6d3b704ae57800 Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:15:46 -0500 Subject: [PATCH] Add global multi-account filter to all reports with chart hover interaction Move account selector to date range bar and apply filtering to all four report types. Add bidirectional hover highlighting between spending donut chart and category table. Fix donut data label readability with white text. Co-Authored-By: Claude Opus 4.6 --- frontend/src/services/reports.ts | 20 ++-- frontend/src/views/reports/ReportsView.vue | 112 +++++++++++++----- .../Controllers/ReportsController.cs | 16 +-- src/Purrse.Api/Services/ReportService.cs | 40 ++++--- .../Interfaces/Services/IReportService.cs | 8 +- 5 files changed, 128 insertions(+), 68 deletions(-) diff --git a/frontend/src/services/reports.ts b/frontend/src/services/reports.ts index a64a9fe..8cfd46c 100644 --- a/frontend/src/services/reports.ts +++ b/frontend/src/services/reports.ts @@ -2,23 +2,27 @@ import api from './api' import type { SpendingByCategoryReport, IncomeVsExpenseReport, NetWorthReport, CashFlowReport } from '@/types' export const reportsApi = { - spendingByCategory: (startDate: string, endDate: string, accountId?: string) => + spendingByCategory: (startDate: string, endDate: string, accountIds?: string[]) => api.get('/reports/spending-by-category', { - params: { startDate, endDate, accountId }, + params: { startDate, endDate, accountIds }, + paramsSerializer: { indexes: null }, }), - incomeVsExpense: (startDate: string, endDate: string) => + incomeVsExpense: (startDate: string, endDate: string, accountIds?: string[]) => api.get('/reports/income-vs-expense', { - params: { startDate, endDate }, + params: { startDate, endDate, accountIds }, + paramsSerializer: { indexes: null }, }), - netWorth: (startDate: string, endDate: string) => + netWorth: (startDate: string, endDate: string, accountIds?: string[]) => api.get('/reports/net-worth', { - params: { startDate, endDate }, + params: { startDate, endDate, accountIds }, + paramsSerializer: { indexes: null }, }), - cashFlow: (startDate: string, endDate: string) => + cashFlow: (startDate: string, endDate: string, accountIds?: string[]) => api.get('/reports/cash-flow', { - params: { startDate, endDate }, + params: { startDate, endDate, accountIds }, + paramsSerializer: { indexes: null }, }), } diff --git a/frontend/src/views/reports/ReportsView.vue b/frontend/src/views/reports/ReportsView.vue index f4c4f3d..6674c3a 100644 --- a/frontend/src/views/reports/ReportsView.vue +++ b/frontend/src/views/reports/ReportsView.vue @@ -38,6 +38,22 @@ style="max-width: 180px" /> + + + + @@ -56,21 +72,7 @@ - - Spending by Category - - - + Spending by Category @@ -78,6 +80,7 @@

Total spending

@@ -266,19 +270,20 @@ const activeTab = ref('spending') const preset = ref('thisMonth') const customStart = ref('') const customEnd = ref('') -const selectedAccountId = ref(undefined) +const selectedAccountIds = ref([]) const loading = ref(false) const snackbar = ref(false) const snackbarText = ref('') +const spendingChartRef = ref() +const highlightedCategory = ref(null) +let selectedChartIndex = -1 + const spending = ref(null) const incomeExpense = ref(null) const netWorth = ref(null) const cashFlow = ref(null) -const accountOptions = computed(() => - [{ id: undefined as string | undefined, name: 'All Accounts' }, ...accountsStore.accounts.map(a => ({ id: a.id, name: a.name }))] -) const dateRange = computed(() => { const now = new Date() @@ -332,10 +337,21 @@ const spendingChartSeries = computed(() => ) const spendingChartOptions = computed(() => ({ - chart: { type: 'donut' as const }, + chart: { + type: 'donut' as const, + events: { + dataPointMouseEnter: (_e: any, _ctx: any, config: any) => { + const labels = spending.value?.categories.map(c => c.categoryName) ?? [] + highlightedCategory.value = labels[config.dataPointIndex] ?? null + }, + dataPointMouseLeave: () => { + highlightedCategory.value = null + }, + }, + }, labels: spending.value?.categories.map(c => c.categoryName) ?? [], legend: { position: 'bottom' as const, labels: { colors: labelColor.value } }, - dataLabels: { style: { colors: undefined }, dropShadow: { enabled: false } }, + dataLabels: { style: { colors: ['#FFFFFF'] }, dropShadow: { enabled: true, top: 1, left: 1, blur: 2, opacity: 0.5 } }, tooltip: { y: { formatter: (val: number) => formatCurrency(val), @@ -343,6 +359,37 @@ const spendingChartOptions = computed(() => ({ }, })) +function highlightCategory(categoryName: string) { + highlightedCategory.value = categoryName + const chart = spendingChartRef.value?.chart + if (!chart) return + const labels = spending.value?.categories.map(c => c.categoryName) ?? [] + const idx = labels.indexOf(categoryName) + if (idx === -1) return + if (selectedChartIndex >= 0 && selectedChartIndex !== idx) { + chart.toggleDataPointSelection(0, selectedChartIndex) + } + if (selectedChartIndex !== idx) { + chart.toggleDataPointSelection(0, idx) + selectedChartIndex = idx + } +} + +function unhighlightCategory() { + highlightedCategory.value = null + const chart = spendingChartRef.value?.chart + if (chart && selectedChartIndex >= 0) { + chart.toggleDataPointSelection(0, selectedChartIndex) + selectedChartIndex = -1 + } +} + +const spendingRowProps = ({ item }: any) => ({ + class: highlightedCategory.value === item.categoryName ? 'spending-row-highlight' : '', + onMouseenter: () => highlightCategory(item.categoryName), + onMouseleave: () => unhighlightCategory(), +}) + // --- Income vs Expense --- const incomeHeaders = [ { title: 'Month', key: 'label' }, @@ -488,11 +535,12 @@ async function loadReports() { loading.value = true const { startDate, endDate } = dateRange.value + const accts = selectedAccountIds.value.length ? selectedAccountIds.value : undefined const [s, ie, nw, cf] = await Promise.all([ - fetchReport(reportsApi.spendingByCategory(startDate, endDate, selectedAccountId.value)), - fetchReport(reportsApi.incomeVsExpense(startDate, endDate)), - fetchReport(reportsApi.netWorth(startDate, endDate)), - fetchReport(reportsApi.cashFlow(startDate, endDate)), + fetchReport(reportsApi.spendingByCategory(startDate, endDate, accts)), + fetchReport(reportsApi.incomeVsExpense(startDate, endDate, accts)), + fetchReport(reportsApi.netWorth(startDate, endDate, accts)), + fetchReport(reportsApi.cashFlow(startDate, endDate, accts)), ]) spending.value = s @@ -502,18 +550,13 @@ async function loadReports() { loading.value = false } -async function loadSpending() { - const { startDate, endDate } = dateRange.value - spending.value = await fetchReport(reportsApi.spendingByCategory(startDate, endDate, selectedAccountId.value)) -} - function showError(text: string) { snackbarText.value = text snackbar.value = true } watch(dateRange, () => loadReports()) -watch(selectedAccountId, () => loadSpending()) +watch(selectedAccountIds, () => loadReports(), { deep: true }) onMounted(async () => { if (accountsStore.accounts.length === 0) { @@ -522,3 +565,10 @@ onMounted(async () => { await loadReports() }) + + diff --git a/src/Purrse.Api/Controllers/ReportsController.cs b/src/Purrse.Api/Controllers/ReportsController.cs index bee1877..0528dde 100644 --- a/src/Purrse.Api/Controllers/ReportsController.cs +++ b/src/Purrse.Api/Controllers/ReportsController.cs @@ -17,20 +17,20 @@ public class ReportsController : ControllerBase public ReportsController(IReportService reportService) => _reportService = reportService; [HttpGet("spending-by-category")] - public async Task> SpendingByCategory([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid? accountId) - => Ok(await _reportService.GetSpendingByCategoryAsync(UserId, Utc(startDate), Utc(endDate), accountId)); + public async Task> SpendingByCategory([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid[]? accountIds) + => Ok(await _reportService.GetSpendingByCategoryAsync(UserId, Utc(startDate), Utc(endDate), accountIds)); [HttpGet("income-vs-expense")] - public async Task> IncomeVsExpense([FromQuery] DateTime startDate, [FromQuery] DateTime endDate) - => Ok(await _reportService.GetIncomeVsExpenseAsync(UserId, Utc(startDate), Utc(endDate))); + public async Task> IncomeVsExpense([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid[]? accountIds) + => Ok(await _reportService.GetIncomeVsExpenseAsync(UserId, Utc(startDate), Utc(endDate), accountIds)); [HttpGet("net-worth")] - public async Task> NetWorth([FromQuery] DateTime startDate, [FromQuery] DateTime endDate) - => Ok(await _reportService.GetNetWorthAsync(UserId, Utc(startDate), Utc(endDate))); + public async Task> NetWorth([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid[]? accountIds) + => Ok(await _reportService.GetNetWorthAsync(UserId, Utc(startDate), Utc(endDate), accountIds)); [HttpGet("cash-flow")] - public async Task> CashFlow([FromQuery] DateTime startDate, [FromQuery] DateTime endDate) - => Ok(await _reportService.GetCashFlowAsync(UserId, Utc(startDate), Utc(endDate))); + public async Task> CashFlow([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid[]? accountIds) + => Ok(await _reportService.GetCashFlowAsync(UserId, Utc(startDate), Utc(endDate), accountIds)); private static DateTime Utc(DateTime dt) => dt.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) : dt; diff --git a/src/Purrse.Api/Services/ReportService.cs b/src/Purrse.Api/Services/ReportService.cs index 9ff055c..f6b5a27 100644 --- a/src/Purrse.Api/Services/ReportService.cs +++ b/src/Purrse.Api/Services/ReportService.cs @@ -12,15 +12,15 @@ public class ReportService : IReportService public ReportService(PurrseDbContext db) => _db = db; - public async Task GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid? accountId = null) + public async Task GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null) { var query = _db.Transactions .Include(t => t.Category).ThenInclude(c => c!.Parent) .Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && t.Amount < 0 && !t.IsVoid && t.CategoryId.HasValue); - if (accountId.HasValue) - query = query.Where(t => t.AccountId == accountId.Value); + if (accountIds is { Length: > 0 }) + query = query.Where(t => accountIds.Contains(t.AccountId)); var groups = await query .GroupBy(t => t.CategoryId) @@ -45,11 +45,13 @@ public class ReportService : IReportService return new SpendingByCategoryReport(startDate, endDate, total, categories); } - public async Task GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate) + public async Task GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null) { - var transactions = await _db.Transactions - .Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer) - .ToListAsync(); + var query = _db.Transactions + .Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer); + if (accountIds is { Length: > 0 }) + query = query.Where(t => accountIds.Contains(t.AccountId)); + var transactions = await query.ToListAsync(); var months = transactions .GroupBy(t => new { t.Date.Year, t.Date.Month }) @@ -65,15 +67,18 @@ public class ReportService : IReportService return new IncomeVsExpenseReport(startDate, endDate, months); } - public async Task GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate) + public async Task GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null) { var accounts = await _db.Accounts.Where(a => a.UserId == userId && a.IsActive).ToListAsync(); + if (accountIds is { Length: > 0 }) + accounts = accounts.Where(a => accountIds.Contains(a.Id)).ToList(); var assetTypes = new[] { AccountType.Checking, AccountType.Savings, AccountType.Cash, AccountType.Brokerage, AccountType.Retirement401k, AccountType.IRA }; - var transactions = await _db.Transactions - .Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid) - .OrderBy(t => t.Date) - .ToListAsync(); + var txQuery = _db.Transactions + .Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid); + if (accountIds is { Length: > 0 }) + txQuery = txQuery.Where(t => accountIds.Contains(t.AccountId)); + var transactions = await txQuery.OrderBy(t => t.Date).ToListAsync(); var entries = new List(); var currentDate = startDate; @@ -94,12 +99,13 @@ public class ReportService : IReportService return new NetWorthReport(entries); } - public async Task GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate) + public async Task GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null) { - var transactions = await _db.Transactions - .Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer) - .OrderBy(t => t.Date) - .ToListAsync(); + var query = _db.Transactions + .Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer); + if (accountIds is { Length: > 0 }) + query = query.Where(t => accountIds.Contains(t.AccountId)); + var transactions = await query.OrderBy(t => t.Date).ToListAsync(); var groups = transactions.GroupBy(t => t.Date.Date) .Select(g => diff --git a/src/Purrse.Core/Interfaces/Services/IReportService.cs b/src/Purrse.Core/Interfaces/Services/IReportService.cs index c8cf9ca..e1408b7 100644 --- a/src/Purrse.Core/Interfaces/Services/IReportService.cs +++ b/src/Purrse.Core/Interfaces/Services/IReportService.cs @@ -4,8 +4,8 @@ namespace Purrse.Core.Interfaces.Services; public interface IReportService { - Task GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid? accountId = null); - Task GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate); - Task GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate); - Task GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate); + Task GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null); + Task GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null); + Task GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null); + Task GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null); }