Refactor: Extract dashboard services from Index page

Extract to separate files for better maintainability:
- Models/Dashboard/DashboardModels.cs - Dashboard DTOs
- Services/DashboardService.cs - All dashboard-related services
  - IDashboardService, IDashboardStatsCalculator
  - ITopCategoriesProvider, IRecentTransactionsProvider
  - ISpendTrendsProvider

Reduces Index.cshtml.cs from 355 lines to 37 lines.

🤖 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:11:48 -05:00
parent 2ceb3a7b57
commit ea7b2c2a3c
3 changed files with 327 additions and 320 deletions

View File

@@ -0,0 +1,62 @@
namespace MoneyMap.Models.Dashboard
{
/// <summary>
/// Statistics displayed on the dashboard.
/// </summary>
public record DashboardStats(
int TotalTransactions = 0,
int Credits = 0,
int Debits = 0,
int Uncategorized = 0,
int Receipts = 0,
int Cards = 0);
/// <summary>
/// Row representing spending in a category.
/// </summary>
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; }
}
/// <summary>
/// Row representing a recent transaction.
/// </summary>
public class RecentTransactionRow
{
public long Id { get; set; }
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; }
}
/// <summary>
/// Spending trends over time.
/// </summary>
public class SpendTrends
{
public List<string> Labels { get; set; } = new();
public List<decimal> DebitsAbs { get; set; } = new();
public List<decimal> Credits { get; set; } = new();
public List<decimal> Net { get; set; } = new();
}
/// <summary>
/// Complete dashboard data package.
/// </summary>
public class DashboardData
{
public required DashboardStats Stats { get; init; }
public required List<TopCategoryRow> TopCategories { get; init; }
public required List<RecentTransactionRow> RecentTransactions { get; init; }
public required SpendTrends Trends { get; init; }
}
}

View File

@@ -1,7 +1,5 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Models.Dashboard;
using MoneyMap.Services;
namespace MoneyMap.Pages
@@ -17,7 +15,7 @@ namespace MoneyMap.Pages
public DashboardStats Stats { get; set; } = new();
public List<TopCategoryRow> TopCategories { get; set; } = new();
public List<RecentTxnRow> Recent { get; set; } = new();
public List<RecentTransactionRow> Recent { get; set; } = new();
public List<string> TrendLabels { get; set; } = new();
public List<decimal> TrendDebitsAbs { get; set; } = new();
public List<decimal> TrendCredits { get; set; } = new();
@@ -35,321 +33,5 @@ namespace MoneyMap.Pages
TrendCredits = dashboard.Trends.Credits;
TrendNet = dashboard.Trends.Net;
}
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 long Id { get; set; }
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;
private readonly ISpendTrendsProvider _spendTrendsProvider;
public DashboardService(
MoneyMapContext db,
IDashboardStatsCalculator statsCalculator,
ITopCategoriesProvider topCategoriesProvider,
IRecentTransactionsProvider recentTransactionsProvider,
ISpendTrendsProvider spendTrendsProvider)
{
_db = db;
_statsCalculator = statsCalculator;
_topCategoriesProvider = topCategoriesProvider;
_recentTransactionsProvider = recentTransactionsProvider;
_spendTrendsProvider = spendTrendsProvider;
}
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);
var trends = await _spendTrendsProvider.GetDailyTrendsAsync(30);
return new DashboardData
{
Stats = stats,
TopCategories = topCategories,
RecentTransactions = recent,
Trends = trends
};
}
}
// ===== 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
{
Id = t.Id,
Date = t.Date,
Name = t.Name,
Memo = t.Memo,
Amount = t.Amount,
Category = t.Category ?? "",
CardLabel = t.PaymentMethodLabel,
ReceiptCount = t.Receipts.Count()
})
.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; }
public required SpendTrends Trends { get; init; }
}
// ===== Spend Trends Provider =====
public interface ISpendTrendsProvider
{
Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30);
}
public class SpendTrendsProvider : ISpendTrendsProvider
{
private readonly MoneyMapContext _db;
public SpendTrendsProvider(MoneyMapContext db)
{
_db = db;
}
public async Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30)
{
var today = DateTime.UtcNow.Date;
var since = today.AddDays(-(lastDays - 1));
var raw = await _db.Transactions
.Where(t => t.Date >= since)
.ExcludeTransfers()
.GroupBy(t => t.Date.Date)
.Select(g => new
{
Date = g.Key,
Debits = g.Where(t => t.Amount < 0).Sum(t => t.Amount),
Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount)
})
.ToListAsync();
var dict = raw.ToDictionary(x => x.Date, x => x);
var labels = new List<string>();
var debitsAbs = new List<decimal>();
var credits = new List<decimal>();
var net = new List<decimal>();
for (var d = since; d <= today; d = d.AddDays(1))
{
labels.Add(d.ToString("yyyy-MM-dd"));
if (dict.TryGetValue(d, out var v))
{
var debit = v.Debits;
var credit = v.Credits;
debitsAbs.Add(Math.Abs(debit));
credits.Add(credit);
net.Add(credit + debit);
}
else
{
debitsAbs.Add(0);
credits.Add(0);
net.Add(0);
}
}
return new SpendTrends
{
Labels = labels,
DebitsAbs = debitsAbs,
Credits = credits,
Net = net
};
}
}
public class SpendTrends
{
public List<string> Labels { get; set; } = new();
public List<decimal> DebitsAbs { get; set; } = new();
public List<decimal> Credits { get; set; } = new();
public List<decimal> Net { get; set; } = new();
}
}

View File

@@ -0,0 +1,263 @@
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models.Dashboard;
namespace MoneyMap.Services
{
/// <summary>
/// Service for retrieving dashboard data.
/// </summary>
public interface IDashboardService
{
Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20);
}
public class DashboardService : IDashboardService
{
private readonly IDashboardStatsCalculator _statsCalculator;
private readonly ITopCategoriesProvider _topCategoriesProvider;
private readonly IRecentTransactionsProvider _recentTransactionsProvider;
private readonly ISpendTrendsProvider _spendTrendsProvider;
public DashboardService(
IDashboardStatsCalculator statsCalculator,
ITopCategoriesProvider topCategoriesProvider,
IRecentTransactionsProvider recentTransactionsProvider,
ISpendTrendsProvider spendTrendsProvider)
{
_statsCalculator = statsCalculator;
_topCategoriesProvider = topCategoriesProvider;
_recentTransactionsProvider = recentTransactionsProvider;
_spendTrendsProvider = spendTrendsProvider;
}
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);
var trends = await _spendTrendsProvider.GetDailyTrendsAsync(30);
return new DashboardData
{
Stats = stats,
TopCategories = topCategories,
RecentTransactions = recent,
Trends = trends
};
}
}
/// <summary>
/// Calculates dashboard statistics.
/// </summary>
public interface IDashboardStatsCalculator
{
Task<DashboardStats> CalculateAsync();
}
public class DashboardStatsCalculator : IDashboardStatsCalculator
{
private readonly MoneyMapContext _db;
public DashboardStatsCalculator(MoneyMapContext db)
{
_db = db;
}
public async Task<DashboardStats> CalculateAsync()
{
var transactionStats = await GetTransactionStatsAsync();
var receiptsCount = await _db.Receipts.CountAsync();
var cardsCount = await _db.Cards.CountAsync();
return new DashboardStats(
transactionStats.Total,
transactionStats.Credits,
transactionStats.Debits,
transactionStats.Uncategorized,
receiptsCount,
cardsCount
);
}
private async Task<TransactionStatsInternal> GetTransactionStatsAsync()
{
var stats = await _db.Transactions
.GroupBy(_ => 1)
.Select(g => new TransactionStatsInternal
{
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 TransactionStatsInternal();
}
private class TransactionStatsInternal
{
public int Total { get; set; }
public int Credits { get; set; }
public int Debits { get; set; }
public int Uncategorized { get; set; }
}
}
/// <summary>
/// Provides top spending categories.
/// </summary>
public interface ITopCategoriesProvider
{
Task<List<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<TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90)
{
var since = DateTime.UtcNow.Date.AddDays(-lastDays);
var expenseTransactions = await _db.Transactions
.Where(t => t.Date >= since && t.Amount < 0)
.ExcludeTransfers()
.ToListAsync();
var totalSpend = expenseTransactions.Sum(t => -t.Amount);
var topCategories = expenseTransactions
.GroupBy(t => t.Category ?? "")
.Select(g => new 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;
}
}
/// <summary>
/// Provides recent transactions.
/// </summary>
public interface IRecentTransactionsProvider
{
Task<List<RecentTransactionRow>> GetRecentTransactionsAsync(int count = 20);
}
public class RecentTransactionsProvider : IRecentTransactionsProvider
{
private readonly MoneyMapContext _db;
public RecentTransactionsProvider(MoneyMapContext db)
{
_db = db;
}
public async Task<List<RecentTransactionRow>> GetRecentTransactionsAsync(int count = 20)
{
return await _db.Transactions
.Include(t => t.Card)
.OrderByDescending(t => t.Date)
.ThenByDescending(t => t.Id)
.Select(t => new RecentTransactionRow
{
Id = t.Id,
Date = t.Date,
Name = t.Name,
Memo = t.Memo,
Amount = t.Amount,
Category = t.Category ?? "",
CardLabel = t.PaymentMethodLabel,
ReceiptCount = t.Receipts.Count()
})
.Take(count)
.AsNoTracking()
.ToListAsync();
}
}
/// <summary>
/// Provides spending trends over time.
/// </summary>
public interface ISpendTrendsProvider
{
Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30);
}
public class SpendTrendsProvider : ISpendTrendsProvider
{
private readonly MoneyMapContext _db;
public SpendTrendsProvider(MoneyMapContext db)
{
_db = db;
}
public async Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30)
{
var today = DateTime.UtcNow.Date;
var since = today.AddDays(-(lastDays - 1));
var raw = await _db.Transactions
.Where(t => t.Date >= since)
.ExcludeTransfers()
.GroupBy(t => t.Date.Date)
.Select(g => new
{
Date = g.Key,
Debits = g.Where(t => t.Amount < 0).Sum(t => t.Amount),
Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount)
})
.ToListAsync();
var dict = raw.ToDictionary(x => x.Date, x => x);
var labels = new List<string>();
var debitsAbs = new List<decimal>();
var credits = new List<decimal>();
var net = new List<decimal>();
for (var d = since; d <= today; d = d.AddDays(1))
{
labels.Add(d.ToString("yyyy-MM-dd"));
if (dict.TryGetValue(d, out var v))
{
var debit = v.Debits;
var credit = v.Credits;
debitsAbs.Add(Math.Abs(debit));
credits.Add(credit);
net.Add(credit + debit);
}
else
{
debitsAbs.Add(0);
credits.Add(0);
net.Add(0);
}
}
return new SpendTrends
{
Labels = labels,
DebitsAbs = debitsAbs,
Credits = credits,
Net = net
};
}
}
}