Feature: Add category pie chart and case-insensitive search

- Add doughnut chart showing spending by category
- Fix search to use EF.Functions.Like for explicit case-insensitivity
- Include Chart.js CDN for chart rendering

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-24 21:12:13 -05:00
parent f42d5e87f9
commit 36a044da4f
2 changed files with 66 additions and 6 deletions

View File

@@ -122,6 +122,17 @@
</div>
</div>
<!-- Pie Chart -->
@if (Model.CategoryBreakdowns.Any())
{
<div class="card shadow-sm mb-3">
<div class="card-header">Spending by category</div>
<div class="card-body">
<canvas id="categoryChart" height="220"></canvas>
</div>
</div>
}
<!-- Transactions Table -->
@if (Model.Transactions.Any())
{
@@ -304,6 +315,31 @@ else
}
@section Scripts {
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="~/js/transactions.js"></script>
<script>
(function(){
const categoryLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.CategoryBreakdowns.Select(c => string.IsNullOrWhiteSpace(c.Category) ? "(uncategorized)" : c.Category)));
const categoryValues = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.CategoryBreakdowns.Select(c => c.TotalSpend)));
const catCtx = document.getElementById('categoryChart');
if (catCtx && categoryLabels.length) {
new Chart(catCtx, {
type: 'doughnut',
data: {
labels: categoryLabels,
datasets: [{
data: categoryValues,
backgroundColor: ['#4e79a7','#f28e2c','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ab']
}]
},
options: {
plugins: { legend: { position: 'bottom' } },
maintainAspectRatio: false
}
});
}
})();
</script>
}

View File

@@ -50,6 +50,7 @@ namespace MoneyMap.Pages
public List<string> AvailableMerchants { get; set; } = new();
public List<Card> AvailableCards { get; set; } = new();
public TransactionStats Stats { get; set; } = new();
public List<CategoryBreakdown> CategoryBreakdowns { get; set; } = new();
public async Task OnGetAsync()
{
@@ -61,15 +62,16 @@ namespace MoneyMap.Pages
.Include(t => t.Merchant)
.AsQueryable();
// Apply filters
// Apply filters (case-insensitive search using EF.Functions.Like)
if (!string.IsNullOrWhiteSpace(Search))
{
var searchPattern = $"%{Search}%";
query = query.Where(t =>
t.Name.Contains(Search) ||
(t.Memo != null && t.Memo.Contains(Search)) ||
(t.Category != null && t.Category.Contains(Search)) ||
(t.Notes != null && t.Notes.Contains(Search)) ||
(t.Merchant != null && t.Merchant.Name.Contains(Search)));
EF.Functions.Like(t.Name, searchPattern) ||
(t.Memo != null && EF.Functions.Like(t.Memo, searchPattern)) ||
(t.Category != null && EF.Functions.Like(t.Category, searchPattern)) ||
(t.Notes != null && EF.Functions.Like(t.Notes, searchPattern)) ||
(t.Merchant != null && EF.Functions.Like(t.Merchant.Name, searchPattern)));
}
if (!string.IsNullOrWhiteSpace(Category))
@@ -154,6 +156,21 @@ namespace MoneyMap.Pages
// Calculate stats for filtered results (all pages, not just current)
Stats = await _statsService.CalculateStatsAsync(query);
// Calculate category breakdown for pie chart (only expenses)
var expenseQuery = query.Where(t => t.Amount < 0).ExcludeTransfers();
var categoryGroups = await expenseQuery
.GroupBy(t => t.Category ?? "")
.Select(g => new CategoryBreakdown
{
Category = g.Key,
TotalSpend = g.Sum(x => -x.Amount),
Count = g.Count()
})
.OrderByDescending(x => x.TotalSpend)
.ToListAsync();
CategoryBreakdowns = categoryGroups;
// Get available categories for filter dropdown
AvailableCategories = await _referenceDataService.GetAvailableCategoriesAsync();
@@ -178,5 +195,12 @@ namespace MoneyMap.Pages
public string AccountLabel { get; set; } = "";
public int ReceiptCount { get; set; }
}
public class CategoryBreakdown
{
public string Category { get; set; } = "";
public decimal TotalSpend { get; set; }
public int Count { get; set; }
}
}
}