Display the average spend per transaction for each category on the dashboard's top expense categories table. This helps users understand spending patterns beyond just total amounts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
265 lines
8.7 KiB
C#
265 lines
8.7 KiB
C#
using System;
|
||
using System.Linq;
|
||
using System.Threading.Tasks;
|
||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using MoneyMap.Data;
|
||
using MoneyMap.Models;
|
||
using MoneyMap.Services;
|
||
|
||
namespace MoneyMap.Pages
|
||
{
|
||
public class IndexModel : PageModel
|
||
{
|
||
private readonly IDashboardService _dashboardService;
|
||
|
||
public IndexModel(IDashboardService dashboardService)
|
||
{
|
||
_dashboardService = dashboardService;
|
||
}
|
||
|
||
public DashboardStats Stats { get; set; } = new();
|
||
public List<TopCategoryRow> TopCategories { get; set; } = new();
|
||
public List<RecentTxnRow> Recent { get; set; } = new();
|
||
|
||
public async Task OnGet()
|
||
{
|
||
var dashboard = await _dashboardService.GetDashboardDataAsync();
|
||
|
||
Stats = dashboard.Stats;
|
||
TopCategories = dashboard.TopCategories;
|
||
Recent = dashboard.RecentTransactions;
|
||
}
|
||
|
||
public record DashboardStats(
|
||
int TotalTransactions = 0,
|
||
int Credits = 0,
|
||
int Debits = 0,
|
||
int Uncategorized = 0,
|
||
int Receipts = 0,
|
||
int Cards = 0);
|
||
|
||
public class TopCategoryRow
|
||
{
|
||
public string Category { get; set; } = "";
|
||
public decimal TotalSpend { get; set; }
|
||
public int Count { get; set; }
|
||
public decimal PercentageOfTotal { get; set; }
|
||
public decimal AveragePerTransaction { get; set; }
|
||
}
|
||
|
||
public class RecentTxnRow
|
||
{
|
||
public DateTime Date { get; set; }
|
||
public string Name { get; set; } = "";
|
||
public string Memo { get; set; } = "";
|
||
public decimal Amount { get; set; }
|
||
public string Category { get; set; } = "";
|
||
public string CardLabel { get; set; } = "";
|
||
public int ReceiptCount { get; set; }
|
||
}
|
||
}
|
||
|
||
// ===== Service Layer =====
|
||
|
||
public interface IDashboardService
|
||
{
|
||
Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20);
|
||
}
|
||
|
||
public class DashboardService : IDashboardService
|
||
{
|
||
private readonly MoneyMapContext _db;
|
||
private readonly IDashboardStatsCalculator _statsCalculator;
|
||
private readonly ITopCategoriesProvider _topCategoriesProvider;
|
||
private readonly IRecentTransactionsProvider _recentTransactionsProvider;
|
||
|
||
public DashboardService(
|
||
MoneyMapContext db,
|
||
IDashboardStatsCalculator statsCalculator,
|
||
ITopCategoriesProvider topCategoriesProvider,
|
||
IRecentTransactionsProvider recentTransactionsProvider)
|
||
{
|
||
_db = db;
|
||
_statsCalculator = statsCalculator;
|
||
_topCategoriesProvider = topCategoriesProvider;
|
||
_recentTransactionsProvider = recentTransactionsProvider;
|
||
}
|
||
|
||
public async Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20)
|
||
{
|
||
var stats = await _statsCalculator.CalculateAsync();
|
||
var topCategories = await _topCategoriesProvider.GetTopCategoriesAsync(topCategoriesCount);
|
||
var recent = await _recentTransactionsProvider.GetRecentTransactionsAsync(recentTransactionsCount);
|
||
|
||
return new DashboardData
|
||
{
|
||
Stats = stats,
|
||
TopCategories = topCategories,
|
||
RecentTransactions = recent
|
||
};
|
||
}
|
||
}
|
||
|
||
// ===== Stats Calculator =====
|
||
|
||
public interface IDashboardStatsCalculator
|
||
{
|
||
Task<IndexModel.DashboardStats> CalculateAsync();
|
||
}
|
||
|
||
public class DashboardStatsCalculator : IDashboardStatsCalculator
|
||
{
|
||
private readonly MoneyMapContext _db;
|
||
|
||
public DashboardStatsCalculator(MoneyMapContext db)
|
||
{
|
||
_db = db;
|
||
}
|
||
|
||
public async Task<IndexModel.DashboardStats> CalculateAsync()
|
||
{
|
||
var transactionStats = await GetTransactionStatsAsync();
|
||
var receiptsCount = await _db.Receipts.CountAsync();
|
||
var cardsCount = await _db.Cards.CountAsync();
|
||
|
||
return new IndexModel.DashboardStats(
|
||
transactionStats.Total,
|
||
transactionStats.Credits,
|
||
transactionStats.Debits,
|
||
transactionStats.Uncategorized,
|
||
receiptsCount,
|
||
cardsCount
|
||
);
|
||
}
|
||
|
||
private async Task<TransactionStats> GetTransactionStatsAsync()
|
||
{
|
||
var stats = await _db.Transactions
|
||
.GroupBy(_ => 1)
|
||
.Select(g => new TransactionStats
|
||
{
|
||
Total = g.Count(),
|
||
Credits = g.Count(t => t.Amount > 0),
|
||
Debits = g.Count(t => t.Amount < 0),
|
||
Uncategorized = g.Count(t => t.Category == null || t.Category == "")
|
||
})
|
||
.FirstOrDefaultAsync();
|
||
|
||
return stats ?? new TransactionStats();
|
||
}
|
||
|
||
private class TransactionStats
|
||
{
|
||
public int Total { get; set; }
|
||
public int Credits { get; set; }
|
||
public int Debits { get; set; }
|
||
public int Uncategorized { get; set; }
|
||
}
|
||
}
|
||
|
||
// ===== Top Categories Provider =====
|
||
|
||
public interface ITopCategoriesProvider
|
||
{
|
||
Task<List<IndexModel.TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90);
|
||
}
|
||
|
||
public class TopCategoriesProvider : ITopCategoriesProvider
|
||
{
|
||
private readonly MoneyMapContext _db;
|
||
|
||
public TopCategoriesProvider(MoneyMapContext db)
|
||
{
|
||
_db = db;
|
||
}
|
||
|
||
public async Task<List<IndexModel.TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90)
|
||
{
|
||
var since = DateTime.UtcNow.Date.AddDays(-lastDays);
|
||
|
||
// Get all expense transactions for the period
|
||
var expenseTransactions = await _db.Transactions
|
||
.Where(t => t.Date >= since && t.Amount < 0)
|
||
.ExcludeTransfers() // Exclude credit card payments and transfers
|
||
.ToListAsync();
|
||
|
||
// Calculate total spend
|
||
var totalSpend = expenseTransactions.Sum(t => -t.Amount);
|
||
|
||
// Group by category and calculate percentages
|
||
var topCategories = expenseTransactions
|
||
.GroupBy(t => t.Category ?? "")
|
||
.Select(g => new IndexModel.TopCategoryRow
|
||
{
|
||
Category = g.Key,
|
||
TotalSpend = g.Sum(x => -x.Amount),
|
||
Count = g.Count(),
|
||
PercentageOfTotal = totalSpend > 0 ? (g.Sum(x => -x.Amount) / totalSpend) * 100 : 0,
|
||
AveragePerTransaction = g.Count() > 0 ? g.Sum(x => -x.Amount) / g.Count() : 0
|
||
})
|
||
.OrderByDescending(x => x.TotalSpend)
|
||
.Take(count)
|
||
.ToList();
|
||
|
||
return topCategories;
|
||
}
|
||
}
|
||
|
||
// ===== Recent Transactions Provider =====
|
||
|
||
public interface IRecentTransactionsProvider
|
||
{
|
||
Task<List<IndexModel.RecentTxnRow>> GetRecentTransactionsAsync(int count = 20);
|
||
}
|
||
|
||
public class RecentTransactionsProvider : IRecentTransactionsProvider
|
||
{
|
||
private readonly MoneyMapContext _db;
|
||
|
||
public RecentTransactionsProvider(MoneyMapContext db)
|
||
{
|
||
_db = db;
|
||
}
|
||
|
||
public async Task<List<IndexModel.RecentTxnRow>> GetRecentTransactionsAsync(int count = 20)
|
||
{
|
||
return await _db.Transactions
|
||
.Include(t => t.Card)
|
||
.OrderByDescending(t => t.Date)
|
||
.ThenByDescending(t => t.Id)
|
||
.Select(t => new IndexModel.RecentTxnRow
|
||
{
|
||
Date = t.Date,
|
||
Name = t.Name,
|
||
Memo = t.Memo,
|
||
Amount = t.Amount,
|
||
Category = t.Category ?? "",
|
||
CardLabel = t.PaymentMethodLabel
|
||
})
|
||
.Take(count)
|
||
.AsNoTracking()
|
||
.ToListAsync();
|
||
}
|
||
|
||
private static string FormatCardLabel(Card? card, string? cardLast4)
|
||
{
|
||
if (card != null)
|
||
return $"{card.Issuer} {card.Last4}";
|
||
|
||
if (string.IsNullOrEmpty(cardLast4))
|
||
return "";
|
||
|
||
return $"<22><><EFBFBD><EFBFBD> {cardLast4}";
|
||
}
|
||
}
|
||
|
||
// ===== Data Transfer Objects =====
|
||
|
||
public class DashboardData
|
||
{
|
||
public required IndexModel.DashboardStats Stats { get; init; }
|
||
public required List<IndexModel.TopCategoryRow> TopCategories { get; init; }
|
||
public required List<IndexModel.RecentTxnRow> RecentTransactions { get; init; }
|
||
}
|
||
} |