Files
MoneyMap/MoneyMap/Pages/Index.cshtml.cs
AJ b2fa1d47a8 Add average cost per transaction to top expense categories
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>
2025-10-12 10:11:17 -04:00

265 lines
8.7 KiB
C#
Raw Blame History

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; }
}
}